From 0415a2ed7daf2a52388877d96e27b6907cf60327 Mon Sep 17 00:00:00 2001 From: busya Date: Wed, 26 Aug 2026 17:03:22 +0300 Subject: [PATCH] chore: accumulate uncommitted workspace changes --- .axiom/axiom_config.yaml | 22 +- .gitignore | 1 + AGENTS.md | 35 +- Makefile | 18 +- agent/src/ss_tools/agent/tools.py | 22 - backend/src/api/auth.py | 8 - .../api/routes/dashboard_testing/__init__.py | 4 +- .../src/api/routes/dashboard_testing/core.py | 2 +- .../routes/dashboard_testing/inheritance.py | 4 +- .../api/routes/dashboard_testing/scenario.py | 2 - .../routes/dashboard_testing/scenario_runs.py | 2 - .../api/routes/dashboard_testing/scenarios.py | 2 - .../api/routes/dashboard_testing/structure.py | 4 +- .../dashboard_testing/structure_snapshot.py | 8 +- .../routes/dashboard_testing/verification.py | 7 +- backend/src/api/routes/dashboards/__init__.py | 19 - backend/src/api/routes/encryption_health.py | 4 - backend/src/api/routes/git/_helpers.py | 16 +- backend/src/api/routes/migration.py | 7 - backend/src/api/routes/reports.py | 13 - backend/src/api/routes/tasks.py | 7 - backend/src/app.py | 31 +- backend/src/core/async_job_runner.py | 4 +- backend/src/core/auth/jwt.py | 1 - backend/src/core/config_manager.py | 4 +- backend/src/core/connection_service.py | 4 +- backend/src/core/encryption.py | 11 - backend/src/core/mapping_service.py | 16 - backend/src/core/migration/risk_assessor.py | 8 - backend/src/core/superset_client/_audit.py | 4 +- backend/src/core/superset_client/_base.py | 4 +- .../src/core/superset_client/_chart_data.py | 20 +- backend/src/core/superset_client/_charts.py | 4 +- .../core/superset_client/_dashboards_crud.py | 4 +- .../superset_client/_dashboards_filters.py | 4 +- .../core/superset_client/_dashboards_list.py | 4 +- .../core/superset_client/_dashboards_write.py | 4 +- .../src/core/superset_client/_databases.py | 4 +- .../core/superset_client/_datasets_preview.py | 4 +- .../_datasets_preview_filters.py | 4 +- .../core/superset_client/_saved_queries.py | 4 +- backend/src/core/superset_client/_sql_lab.py | 4 +- backend/src/core/task_manager/context.py | 13 - backend/src/core/task_manager/manager.py | 67 +- backend/src/core/task_manager/persistence.py | 53 +- backend/src/core/task_manager/task_logger.py | 13 - backend/src/core/utils/network.py | 24 +- .../utils/superset_context_extractor/_base.py | 4 +- .../superset_context_extractor/_filters.py | 4 +- .../superset_context_extractor/_parsing.py | 4 +- .../superset_context_extractor/_recovery.py | 4 +- .../superset_context_extractor/_templates.py | 4 +- backend/src/models/mapping.py | 1 - backend/src/models/report.py | 76 - backend/src/models/task.py | 54 - .../src/plugins/llm_analysis/_constants.py | 16 + .../plugins/llm_analysis/_dataset_health.py | 215 ++ .../llm_analysis/_llm_client_analysis.py | 351 ++++ .../plugins/llm_analysis/_llm_client_core.py | 391 ++++ .../src/plugins/llm_analysis/_redaction.py | 60 + .../src/plugins/llm_analysis/_screenshot.py | 39 + .../llm_analysis/_screenshot_capture.py | 256 +++ .../plugins/llm_analysis/_screenshot_login.py | 264 +++ .../plugins/llm_analysis/_screenshot_media.py | 102 + .../llm_analysis/_screenshot_session.py | 187 ++ .../plugins/llm_analysis/_screenshot_wait.py | 88 + .../scripts/superset_auth_diag.py | 52 + backend/src/plugins/llm_analysis/service.py | 1847 +---------------- backend/src/plugins/maintenance_banner.py | 4 +- backend/src/plugins/storage/plugin.py | 4 +- backend/src/plugins/translate/_batch_proc.py | 4 +- backend/src/plugins/translate/_batch_sizer.py | 4 +- backend/src/plugins/translate/_llm_call.py | 4 +- backend/src/plugins/translate/_run_service.py | 4 +- .../src/plugins/translate/_token_budget.py | 1 - backend/src/plugins/translate/events.py | 4 +- backend/src/plugins/translate/executor.py | 5 +- backend/src/plugins/translate/metrics.py | 12 +- backend/src/plugins/translate/orchestrator.py | 4 +- .../plugins/translate/orchestrator_retry.py | 4 +- .../translate/orchestrator_sql_rows.py | 5 - backend/src/plugins/translate/preview.py | 4 +- .../src/plugins/translate/prompt_builder.py | 4 +- backend/src/plugins/translate/scheduler.py | 4 +- backend/src/plugins/translate/service.py | 4 +- .../src/plugins/translate/sql_generator.py | 4 +- .../plugins/translate/superset_executor.py | 4 +- .../src/schemas/dashboard_testing/__init__.py | 4 +- .../schemas/dashboard_testing/candidates.py | 4 +- .../src/schemas/dashboard_testing/capture.py | 4 +- .../src/schemas/dashboard_testing/catalog.py | 4 +- .../src/schemas/dashboard_testing/common.py | 4 +- .../src/schemas/dashboard_testing/enums.py | 4 +- .../schemas/dashboard_testing/execution.py | 4 +- .../src/schemas/dashboard_testing/filters.py | 4 +- .../schemas/dashboard_testing/inheritance.py | 4 +- .../schemas/dashboard_testing/query_model.py | 4 +- .../src/schemas/dashboard_testing/results.py | 4 +- .../dashboard_testing/scenario_registry.py | 4 +- .../dashboard_testing/structure_diff.py | 8 +- .../dashboard_testing/structure_snapshot.py | 4 +- .../schemas/dashboard_testing/verification.py | 4 +- backend/src/scripts/prepare_database.py | 23 + backend/src/scripts/reencrypt.py | 23 +- .../test_dataset_dashboard_relations.py | 1 - .../compliance_execution_service.py | 4 +- .../clean_release/compliance_orchestrator.py | 6 - .../services/clean_release/policy_engine.py | 7 - .../services/clean_release/report_builder.py | 6 - .../dashboard_testing/automation/schedule.py | 1 - .../services/dashboard_testing/comparison.py | 4 +- .../dashboard_testing/execution/approval.py | 1 - .../dashboard_testing/execution/artifacts.py | 1 - .../dashboard_testing/execution/comparison.py | 1 - .../dashboard_testing/execution/lifecycle.py | 1 - .../execution/runner_plan.py | 1 - .../dashboard_testing/registry/create.py | 1 - .../dashboard_testing/registry/get.py | 1 - .../dashboard_testing/registry/lifecycle.py | 1 - .../dashboard_testing/registry/list.py | 2 - .../dashboard_testing/registry/revisions.py | 1 - .../dashboard_testing/registry/staleness.py | 1 - .../scenario/capability_mapper.py | 4 - .../dashboard_testing/scenario/capture.py | 2 - .../scenario/checklist_catalog.py | 1 - .../dashboard_testing/scenario/compiler.py | 3 - .../dashboard_testing/scenario/disposition.py | 2 - .../scenario/pack_compiler.py | 3 - .../scenario/pack_registry.py | 2 - .../dashboard_testing/scenario/validator.py | 4 - .../dashboard_testing/scenario/vlm.py | 3 - .../structure_snapshot_capture.py | 4 +- .../structure_snapshot_diff.py | 4 +- .../dashboard_testing/visual_baseline.py | 6 +- backend/src/services/git/_base.py | 4 +- backend/src/services/git/_branch.py | 4 +- backend/src/services/git/_gitea.py | 4 +- backend/src/services/git/_merge.py | 4 +- backend/src/services/git/_sync.py | 4 +- backend/src/services/git/_url.py | 4 +- backend/src/services/lineage/deprecation.py | 1 - .../services/load_testing/bounded_result.py | 1 - backend/src/services/load_testing/capacity.py | 1 - backend/src/services/load_testing/profile.py | 1 - backend/src/services/profile_service.py | 6 - backend/src/services/reports/normalizer.py | 14 - .../src/services/reports/report_service.py | 12 - backend/src/services/reports/type_profiles.py | 18 - .../plugins/test_llm_analysis_service.py | 48 +- docs/adr/ADR-0002-semantic-protocol.md | 17 +- docs/api/Doxyfile | 8 +- frontend/src/lib/api/translate/datasources.ts | 1 - frontend/src/lib/api/translate/jobs.ts | 2 - frontend/src/lib/api/translate/runs.ts | 3 - frontend/src/lib/api/translate/schedules.ts | 3 - .../lib/components/layout/TaskDrawer.svelte | 6 - .../lib/components/layout/TopNavbar.svelte | 4 - .../lib/components/reports/ReportCard.svelte | 12 - .../reports/ReportDetailPanel.svelte | 12 - .../lib/components/reports/ReportsList.svelte | 11 - .../components/reports/reportTypeProfiles.ts | 11 - .../ConstrainedAssertionEditor.svelte | 1 - .../src/lib/models/ReportsLogModel.svelte.ts | 4 +- .../src/routes/admin/settings/+page.svelte | 4 +- frontend/src/routes/migration/+page.svelte | 9 - frontend/src/routes/settings/git/+page.svelte | 4 +- frontend/src/routes/storage/+page.svelte | 4 +- scripts/semantic_health.py | 186 ++ scripts/sync-skills.sh | 15 + specs/036-agent-test-stabilization/spec.md | 15 +- specs/037-superset-baseline-engine/spec.md | 6 + specs/038-dashboard-scenario-model/spec.md | 17 +- specs/039-dashboard-scenario-ui/spec.md | 9 + specs/040-dashboard-load-testing/spec.md | 5 + .../041-dataset-lineage-blast-radius/spec.md | 5 + specs/042-dashboard-scenario-registry/spec.md | 5 + specs/043-dashboard-scenario-editor/spec.md | 5 + .../044-dashboard-scenario-execution/spec.md | 6 + specs/045-dashboard-run-monitor/spec.md | 5 + .../046-dashboard-scenario-automation/spec.md | 5 + .../047-dashboard-scenario-analytics/spec.md | 5 + specs/050-mcp-interface/spec.md | 240 +++ specs/050-mcp-interface/tasks.md | 49 + 183 files changed, 3015 insertions(+), 2620 deletions(-) create mode 100644 backend/src/plugins/llm_analysis/_constants.py create mode 100644 backend/src/plugins/llm_analysis/_dataset_health.py create mode 100644 backend/src/plugins/llm_analysis/_llm_client_analysis.py create mode 100644 backend/src/plugins/llm_analysis/_llm_client_core.py create mode 100644 backend/src/plugins/llm_analysis/_redaction.py create mode 100644 backend/src/plugins/llm_analysis/_screenshot.py create mode 100644 backend/src/plugins/llm_analysis/_screenshot_capture.py create mode 100644 backend/src/plugins/llm_analysis/_screenshot_login.py create mode 100644 backend/src/plugins/llm_analysis/_screenshot_media.py create mode 100644 backend/src/plugins/llm_analysis/_screenshot_session.py create mode 100644 backend/src/plugins/llm_analysis/_screenshot_wait.py create mode 100644 scripts/semantic_health.py create mode 100755 scripts/sync-skills.sh create mode 100644 specs/050-mcp-interface/spec.md create mode 100644 specs/050-mcp-interface/tasks.md diff --git a/.axiom/axiom_config.yaml b/.axiom/axiom_config.yaml index d0e991498..037ee7cc4 100644 --- a/.axiom/axiom_config.yaml +++ b/.axiom/axiom_config.yaml @@ -31,7 +31,6 @@ indexing: - '*.html' - 'coverage_html_frontend/' - 'coverage/' - - agent/ - '*,cover' - docker/ source_dirs: @@ -42,9 +41,16 @@ indexing: - backend/tests - frontend/src - frontend/tests + - agent/src + - agent/tests + - shared/src doc_dirs: - docs - specs + - .agents + - .agents/agents + - .agents/skills + - .agents/commands - .opencode - .specify - .opencode/agents @@ -246,7 +252,7 @@ tags: multiline: false is_reference: true description: 'Graph dependency edge. Links contracts. Recommended on any function/module with external dependencies.' - allowed_predicates: [DEPENDS_ON, CALLS, INHERITS, IMPLEMENTS, DISPATCHES, BINDS_TO, CALLED_BY, VERIFIES, USES, CONTAINS, BELONGS_TO, ASSOCIATED_WITH] + allowed_predicates: [DEPENDS_ON, CALLS, INHERITS, IMPLEMENTS, DISPATCHES, BINDS_TO, CALLED_BY, VERIFIES] PRE: type: string multiline: true @@ -258,12 +264,12 @@ tags: RATIONALE: type: string multiline: true - description: 'Architectural decision rationale. WHY this implementation was chosen. Decision Memory — prevents regression loops.' + description: 'Architectural decision rationale. WHY this implementation was chosen. Optional. Omit rather than invent. Synthetic text is a defect (INV_9).' decision_memory: true REJECTED: type: string multiline: true - description: 'Rejected alternative and disqualification reason. WHAT was tried and WHY it failed. Decision Memory — active guardrail against re-implementation.' + description: 'Rejected alternative and disqualification reason. Optional. Omit rather than invent a generic alternative. Synthetic text is a defect (INV_9).' decision_memory: true DATA_CONTRACT: type: string @@ -438,13 +444,13 @@ belief_runtime: # #endregion AxiomConfig.BeliefRuntime # #region AxiomConfig.ComplexityRules [C:2] [TYPE Block] [SEMANTICS config,complexity,rules] -# @BRIEF Per-tier tag requirements from GRACE-Poly SSOT. All tags allowed everywhere, -# but C4+ require formal contract annotations. +# @BRIEF Advisory typical tags from GRACE-Poly SSOT. Empty required lists: +# missing tags are not violations (INV_9). Do not fill tags to silence audits. complexity_rules: "4": - required: [PRE, POST, SIDE_EFFECT, DATA_CONTRACT] + required: [] "5": - required: [PRE, POST, SIDE_EFFECT, DATA_CONTRACT, INVARIANT] + required: [] # #endregion AxiomConfig.ComplexityRules diff --git a/.gitignore b/.gitignore index 722dc3915..1afa86371 100755 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,7 @@ e2e_*.png #generated doxygen docs/api/html +docs/api/nav/ docs/api/build/ superset-tools.bundle diff --git a/AGENTS.md b/AGENTS.md index 0ce429ff2..d34ac7dd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,20 +163,37 @@ CLI-бинарём `doc-gen` из Rust-проекта `axiom-mcp` (соседн # 1. Собрать бинарник (один раз) cargo build --release --bin doc-gen --manifest-path ../axiom-mcp/Cargo.toml -# 2. Переиндексировать workspace и собрать Doxygen HTML +# 2. Фрактальный граф: модули → функции (карты + Doxygen HTML) +make docs-nav +# эквивалент: ../axiom-mcp/target/release/doc-gen \ --workspace-root /home/busya/dev/ss-tools \ - --html /tmp/ss-tools-doxygen - -# 3. Только навигационный граф (без doxygen) -../axiom-mcp/target/release/doc-gen \ - --workspace-root /home/busya/dev/ss-tools \ - --nav /tmp/ss-tools-nav + --nav docs/api/nav \ + --html docs/api/html ``` +Как ходить по графу: + +1. **Модули** — `docs/api/nav/root.map` (или HTML mainpage). Не читать функции с корня. +2. **Функции** — `docs/api/nav/.map` секция `@FUNCTIONS`, либо Doxygen group → `\ingroup` страница функции. + Примечания: - `--workspace-root` передавать **абсолютным** путём (относительный путь ломает проверку `safe_join` при записи в DuckDB). -- Результат doxygen: `.dox`-страницы, `Doxyfile` и каталог `html/` внутри `--html`-директории. -- Остальные опции: `doc-gen --help` (формат, per-file вывод, `--filter`, `--group-cap`, `--body-lines`). +- `make docs-doxygen` — отдельный XML/HTML extract из исходных комментариев в `docs/api/build`. +- Навигационный граф агента — `doc-gen --nav/--html`, не плоский список `axiom_*.html`. +- Остальные опции: `doc-gen --help` (`--filter`, `--group-cap`, `--body-lines`). + +## Agent prompts and skills + +`.agents/skills/` is the canonical source for semantic skills. The Kilo runtime +loads the generated copy from `.kilo/skills/`; after changing a skill, run: + +```bash +./scripts/sync-skills.sh +``` + +Do not edit `.kilo/skills/` directly. Agent prompts follow the same source/runtime +split: canonical prompts are in `.agents/agents/` and the Kilo-loaded copies are +in `.kilo/agents/`. diff --git a/Makefile b/Makefile index dc81a560e..8a23a4174 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ TIMEOUT_SLOW := 600 .PHONY: help test test-unit test-frontend test-related test-integration test-e2e test-all .PHONY: coverage coverage-backend coverage-frontend .PHONY: lint lint-backend lint-frontend -.PHONY: docs-doxygen docs-doxygen-check +.PHONY: docs-doxygen docs-doxygen-check docs-nav # ── Help ─────────────────────────────────────────────────── help: ## Show this help message @@ -88,7 +88,21 @@ lint-frontend: ## Frontend eslint @cd $(FRONTEND) && npx eslint . # ── Documentation ─────────────────────────────────────────── -docs-doxygen: ## Generate Doxygen XML and HTML documentation +DOC_GEN ?= $(ROOT)/../axiom-mcp/target/release/doc-gen + +docs-nav: ## Fractal module→function navigation graph (doc-gen --nav + --html) + @if [ ! -x "$(DOC_GEN)" ]; then \ + echo " ▶ Building doc-gen..."; \ + cargo build --release --bin doc-gen --manifest-path "$(ROOT)/../axiom-mcp/Cargo.toml"; \ + fi + @echo " ▶ Navigation graph (modules → functions)..." + @LD_LIBRARY_PATH="$(ROOT)/../axiom-mcp/target/release/deps:$$LD_LIBRARY_PATH" \ + "$(DOC_GEN)" --workspace-root "$(ROOT)" --nav "$(ROOT)/docs/api/nav" --html "$(ROOT)/docs/api/html" + @test -f "$(ROOT)/docs/api/nav/root.map" + @echo " ✅ Nav maps: docs/api/nav/root.map" + @echo " ✅ HTML: docs/api/html/index.html" + +docs-doxygen: ## Generate Doxygen XML and HTML documentation from source comments @if ! command -v doxygen >/dev/null 2>&1; then \ echo " ✗ doxygen not found."; \ echo " Debian/Ubuntu: sudo apt-get install -y doxygen"; \ diff --git a/agent/src/ss_tools/agent/tools.py b/agent/src/ss_tools/agent/tools.py index b163771c1..c222d9928 100644 --- a/agent/src/ss_tools/agent/tools.py +++ b/agent/src/ss_tools/agent/tools.py @@ -155,9 +155,6 @@ def _guard_tool_permission(tool_name: str) -> None: # @BRIEF Fixed-delay retry wrapper for read-only tool HTTP calls on transient errors. # @PRE Tool is read-only (risk_level="safe"). Error is 5xx or httpx.ConnectError. # @POST Retries once with fixed 1s asyncio.sleep. On success: returns response. On exhaust: raises with retry_exhausted=True. -# @TEST_EDGE: first_attempt_502 -> Auto-retries once, succeeds. -# @TEST_EDGE: both_attempts_502 -> Raises error with retry_exhausted=True. -# @TEST_EDGE: write_tool_502 -> No retry, raises immediately. # @RATIONALE 1 retry with 1s delay catches ~80% of transient failures. Read-only only. # @REJECTED Exponential backoff — single retry cannot be exponential. # @REJECTED Retry all tools — write operations not idempotent. @@ -237,7 +234,6 @@ def _summarise_response(text: str, limit: int = 4000) -> str: # @ingroup AgentChat # @BRIEF Configurable timeout wrapper (default 30s). Write tools: retryable=false. # @POST Returns tool result within timeout. On TimeoutError: yields tool_timeout SSE. -# @TEST_EDGE: complete_under_timeout→normal, read_exceed→retryable timeout, write_exceed→retryable=false async def _execute_with_timeout(tool_name: str, tool_fn, is_write: bool = False, timeout_s: int = 30): """Execute tool with configurable timeout. Write tools: no retry on timeout. @@ -365,7 +361,6 @@ class SearchDashboardsInput(BaseModel): # #region AgentChat.Tools.SearchDashboards [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,search,dashboard] # @ingroup AgentChat # @BRIEF Search and list dashboards by name, with optional environment filter. -# @PRE User authenticated via dual-identity JWT. # @POST Returns formatted dashboard list string. # @POST Response surfaces effective profile filter metadata so hidden dashboards are never reported as absent. # @RATIONALE Agent search must see the full environment catalog: without page_context=other the backend @@ -460,7 +455,6 @@ class HealthSummaryInput(BaseModel): # #region AgentChat.Tools.HealthSummary [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,health,summary] # @ingroup AgentChat # @BRIEF Get system health summary — dashboard validation status, recent failures. -# @PRE User authenticated via dual-identity JWT. # @POST Returns health summary text (trimmed to 2000 chars). # @SIDE_EFFECT HTTP GET to FastAPI /api/health/summary. @tool(args_schema=HealthSummaryInput) @@ -485,7 +479,6 @@ async def get_health_summary(env_id: str | None = None) -> str: # #region AgentChat.Tools.ListEnvironments [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,environment,list] # @ingroup AgentChat # @BRIEF List configured deployment environments. -# @PRE User authenticated via dual-identity JWT. # @POST Returns JSON string of environments (sensitive fields redacted). # @SIDE_EFFECT HTTP GET to FastAPI /api/settings/environments. @tool @@ -503,7 +496,6 @@ async def list_environments() -> str: # #region AgentChat.Tools.TaskStatus [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,task,status] # @ingroup AgentChat # @BRIEF Check the status of a background task by its task_id. -# @PRE User authenticated via dual-identity JWT. # @POST Returns task status text from FastAPI. # @SIDE_EFFECT HTTP GET to FastAPI /api/tasks/{task_id}. @tool @@ -535,7 +527,6 @@ async def show_capabilities() -> str: # #region AgentChat.Tools.ListLlmProviders [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,llm,providers] # @ingroup AgentChat # @BRIEF List configured LLM providers. -# @PRE User authenticated via dual-identity JWT. # @POST Returns provider list text. # @SIDE_EFFECT HTTP GET to FastAPI /api/llm/providers. @tool @@ -553,7 +544,6 @@ async def list_llm_providers() -> str: # #region AgentChat.Tools.LlmStatus [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,llm,status] # @ingroup AgentChat # @BRIEF Check whether the LLM runtime is configured and usable. -# @PRE User authenticated via dual-identity JWT. # @POST Returns LLM status text. # @SIDE_EFFECT HTTP GET to FastAPI /api/llm/status. @tool @@ -579,7 +569,6 @@ class RunBackupInput(BaseModel): # #region AgentChat.Tools.RunBackup [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,backup,operation] # @ingroup AgentChat # @BRIEF Run a Superset backup for an environment, optionally scoped to dashboard IDs. -# @PRE User authenticated via dual-identity JWT. # @POST Returns task creation result (201). # @SIDE_EFFECT HTTP POST to FastAPI /api/tasks (superset-backup plugin). @tool(args_schema=RunBackupInput) @@ -617,7 +606,6 @@ class ExecuteMigrationInput(BaseModel): # #region AgentChat.Tools.ExecuteMigration [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,migration,operation] # @ingroup AgentChat # @BRIEF Execute dashboard migration between two environments. -# @PRE User authenticated via dual-identity JWT. # @POST Returns migration result text. # @SIDE_EFFECT HTTP POST to FastAPI /api/migration/execute. @tool(args_schema=ExecuteMigrationInput) @@ -667,7 +655,6 @@ class CreateBranchInput(GitDashboardInput): # #region AgentChat.Tools.CreateBranch [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,git,branch] # @ingroup AgentChat # @BRIEF Create a branch in a dashboard Git repository. -# @PRE User authenticated via dual-identity JWT. # @POST Returns branch creation result. # @SIDE_EFFECT HTTP POST to FastAPI /api/git/repositories/{ref}/branches. @tool(args_schema=CreateBranchInput) @@ -704,7 +691,6 @@ class CommitChangesInput(GitDashboardInput): # #region AgentChat.Tools.CommitChanges [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,git,commit] # @ingroup AgentChat # @BRIEF Stage and commit changes in a dashboard Git repository. -# @PRE User authenticated via dual-identity JWT. # @POST Returns commit result. # @SIDE_EFFECT HTTP POST to FastAPI /api/git/repositories/{ref}/commit. @tool(args_schema=CommitChangesInput) @@ -740,7 +726,6 @@ class DeployDashboardInput(GitDashboardInput): # #region AgentChat.Tools.DeployDashboard [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,git,deploy] # @ingroup AgentChat # @BRIEF Deploy a dashboard from Git to a target environment. -# @PRE User authenticated via dual-identity JWT. # @POST Returns deployment result. # @SIDE_EFFECT HTTP POST to FastAPI /api/git/repositories/{ref}/deploy. @tool(args_schema=DeployDashboardInput) @@ -892,7 +877,6 @@ async def run_llm_validation( # #region AgentChat.Tools.ListMaintenance [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,maintenance,list] # @ingroup AgentChat # @BRIEF List active and completed maintenance events. -# @PRE User authenticated via dual-identity JWT. # @POST Returns maintenance events text. # @SIDE_EFFECT HTTP GET to FastAPI /api/maintenance/events. @tool @@ -920,7 +904,6 @@ class StartMaintenanceInput(BaseModel): # #region AgentChat.Tools.StartMaintenance [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,maintenance,start] # @ingroup AgentChat # @BRIEF Start a maintenance event and apply banners to affected dashboards. -# @PRE User authenticated via dual-identity JWT. # @POST Returns maintenance start result (200/202). # @SIDE_EFFECT HTTP POST to FastAPI /api/maintenance/start. @tool(args_schema=StartMaintenanceInput) @@ -962,7 +945,6 @@ class EndMaintenanceInput(BaseModel): # #region AgentChat.Tools.EndMaintenance [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,maintenance,end] # @ingroup AgentChat # @BRIEF End one maintenance event, or end all active events when end_all is true. -# @PRE User authenticated via dual-identity JWT. # @POST Returns maintenance end result (202). # @SIDE_EFFECT HTTP POST to FastAPI /api/maintenance/{id}/end or /api/maintenance/end-all. @tool(args_schema=EndMaintenanceInput) @@ -1139,7 +1121,6 @@ class AuditPermissionsInput(BaseModel): # #region AgentChat.Tools.SupersetAudit [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,superset,audit,permissions] # @ingroup AgentChat # @BRIEF Audit access rights: user x dashboard x dataset x RLS permission matrix. -# @PRE User authenticated via dual-identity JWT. # @POST Returns audit report with per-user dashboard/dataset access and RLS regions. # @SIDE_EFFECT HTTP GET to FastAPI /api/agent/superset/audit/permissions. @tool(args_schema=AuditPermissionsInput) @@ -1176,7 +1157,6 @@ class CreateSupersetDashboardInput(BaseModel): # #region AgentChat.Tools.SupersetCreateDashboard [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,superset,dashboard,create] # @ingroup AgentChat # @BRIEF Create a new dashboard in Superset. -# @PRE User authenticated via dual-identity JWT. # @POST Returns created dashboard dict. # @SIDE_EFFECT HTTP POST to FastAPI /api/agent/superset/dashboards. @tool(args_schema=CreateSupersetDashboardInput) @@ -1215,7 +1195,6 @@ class CopySupersetDashboardInput(BaseModel): # #region AgentChat.Tools.SupersetCopyDashboard [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,superset,dashboard,copy] # @ingroup AgentChat # @BRIEF Deep-copy a Superset dashboard including all charts. -# @PRE User authenticated via dual-identity JWT. # @POST Returns copy result dict. # @SIDE_EFFECT HTTP POST to FastAPI /api/agent/superset/dashboards/{id}/copy. @tool(args_schema=CopySupersetDashboardInput) @@ -1250,7 +1229,6 @@ class CreateSupersetDatasetInput(BaseModel): # #region AgentChat.Tools.SupersetCreateDataset [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,superset,dataset,create] # @ingroup AgentChat # @BRIEF Create a new dataset in Superset. -# @PRE User authenticated via dual-identity JWT. # @POST Returns created dataset dict. # @SIDE_EFFECT HTTP POST to FastAPI /api/agent/superset/datasets. @tool(args_schema=CreateSupersetDatasetInput) diff --git a/backend/src/api/auth.py b/backend/src/api/auth.py index 638111753..4ef3f647d 100755 --- a/backend/src/api/auth.py +++ b/backend/src/api/auth.py @@ -43,9 +43,6 @@ router = APIRouter(prefix="/api/auth", tags=["auth"]) # @SIDE_EFFECT DB read for user verification; writes security event LOGIN. # @SIDE_EFFECT Molecular CoT: REASON on entry, REFLECT on success, EXPLORE on failure. # @RELATION CALLS -> [Services.Auth.Service] -# @TEST_EDGE: invalid_credentials -> 401 -# @TEST_EDGE: locked_account -> 423 -# @TEST_EDGE: missing_fields -> 422 @router.post("/login", response_model=Token) async def login_for_access_token( @@ -186,8 +183,6 @@ async def get_session_policy( # @RELATION DEPENDS_ON -> [Dependencies.AppDependencies.GetCurrentUser] # @RELATION DEPENDS_ON -> [Dependencies.AppDependencies.TouchSessionActivity] # @RELATION DEPENDS_ON -> [Services.Auth.Service] -# @TEST_EDGE: idle_expired -> 401 SESSION_IDLE_EXPIRED -# @TEST_EDGE: renewed -> 200 with new access_token and same sid @router.post("/session/activity", response_model=SessionActivityResponse) async def post_session_activity( @@ -256,7 +251,6 @@ async def post_session_activity( # @RELATION DEPENDS_ON -> [Dependencies.AppDependencies.GetCurrentUser] # @RELATION CALLS -> [Auth.Jwt.BlacklistToken] # @RELATION CALLS -> [Services.Auth.Service] -# @TEST_EDGE: already_expired_token -> 200 (idempotent) @router.post("/logout") async def logout( @@ -321,8 +315,6 @@ async def login_adfs(request: starlette.requests.Request): # @SIDE_EFFECT DB write for user provisioning; writes security event LOGIN_ADFS. # @SIDE_EFFECT Molecular CoT: REASON/REFLECT/EXPLORE markers. # @RELATION CALLS -> [Services.Auth.Service] -# @TEST_EDGE: adfs_timeout -> 504 -# @TEST_EDGE: invalid_state -> 401 @router.get("/callback/adfs", name="auth_callback_adfs") async def auth_callback_adfs( diff --git a/backend/src/api/routes/dashboard_testing/__init__.py b/backend/src/api/routes/dashboard_testing/__init__.py index 53aab0c6a..235bebf8f 100644 --- a/backend/src/api/routes/dashboard_testing/__init__.py +++ b/backend/src/api/routes/dashboard_testing/__init__.py @@ -1,4 +1,4 @@ -#region Api.DashboardTesting.Package [C:4] [TYPE Module] [SEMANTICS baseline,api,routes,dashboard-testing,package] +# #region Api.DashboardTesting.Package [C:4] [TYPE Module] [SEMANTICS baseline,api,routes,dashboard-testing,package] # @defgroup Api Dashboard testing API route package — core + structure-diff + structure-snapshot + verification-runs. # @LAYER API # @BRIEF Combined router from submodules: core (existing), structure (feature-037), @@ -50,4 +50,4 @@ router.include_router(scenario_automation_router) # Re-export for backward-compatible imports __all__ = ["router"] -#endregion Api.DashboardTesting.Package +# #endregion Api.DashboardTesting.Package diff --git a/backend/src/api/routes/dashboard_testing/core.py b/backend/src/api/routes/dashboard_testing/core.py index f2047ebcd..921c2ee93 100644 --- a/backend/src/api/routes/dashboard_testing/core.py +++ b/backend/src/api/routes/dashboard_testing/core.py @@ -1,4 +1,4 @@ -#region Api.DashboardTesting [C:4] [TYPE Module] [SEMANTICS baseline,api,routes,dashboard-testing] +# #region Api.DashboardTesting [C:4] [TYPE Module] [SEMANTICS baseline,api,routes,dashboard-testing] # @defgroup Api Dashboard testing API routes — inspection, execution, comparison, baselines, candidates. # @LAYER API # @RELATION DEPENDS_ON -> [BaselineEngine.QueryModel.Inspect] diff --git a/backend/src/api/routes/dashboard_testing/inheritance.py b/backend/src/api/routes/dashboard_testing/inheritance.py index dd90b2128..2f74f1305 100644 --- a/backend/src/api/routes/dashboard_testing/inheritance.py +++ b/backend/src/api/routes/dashboard_testing/inheritance.py @@ -1,4 +1,4 @@ -#region Api.DashboardTesting.Inheritance [C:4] [TYPE Module] [SEMANTICS baseline,api,inheritance,plan,execute] +# #region Api.DashboardTesting.Inheritance [C:4] [TYPE Module] [SEMANTICS baseline,api,inheritance,plan,execute] # @defgroup Api Baseline inheritance API endpoints — plan and execute. # @LAYER API # @RELATION DEPENDS_ON -> [BaselineEngine.Inheritance.PlanInheritance] @@ -174,4 +174,4 @@ async def inheritance_execute( ) from err # #endregion Api.DashboardTesting.Inheritance.ExecuteEndpoint -#endregion Api.DashboardTesting.Inheritance +# #endregion Api.DashboardTesting.Inheritance diff --git a/backend/src/api/routes/dashboard_testing/scenario.py b/backend/src/api/routes/dashboard_testing/scenario.py index 7d215fc1b..129a3c51e 100644 --- a/backend/src/api/routes/dashboard_testing/scenario.py +++ b/backend/src/api/routes/dashboard_testing/scenario.py @@ -107,7 +107,6 @@ class ScenarioDispositionRequest(BaseModel): # @BRIEF Deterministically compile a dashboard goal into a ScenarioGraph. # @PRE Caller has dashboard-testing WRITE permission. # @POST Returns ScenarioResponse (graph + validation) or 422 for invalid canonical inputs. -# @TEST_EDGE invalid_inputs -> 422 VALIDATION_ERROR. @router.post("/scenarios/compile", status_code=status.HTTP_200_OK) def api_compile_scenario( req: ScenarioCompileRequest, @@ -250,7 +249,6 @@ def api_draft_pack( # @ingroup Api # @BRIEF Execute a screenshot capture step via the 036 Evidence bridge. # @POST Returns artifact refs (original + masked when mask_selectors present). -# @TEST_EDGE invalid_profile -> 422. @router.post("/scenarios/{scenario_id}/capture", status_code=status.HTTP_200_OK) def api_capture_screenshot( scenario_id: str, diff --git a/backend/src/api/routes/dashboard_testing/scenario_runs.py b/backend/src/api/routes/dashboard_testing/scenario_runs.py index 07397fc07..0fd518b3a 100644 --- a/backend/src/api/routes/dashboard_testing/scenario_runs.py +++ b/backend/src/api/routes/dashboard_testing/scenario_runs.py @@ -313,8 +313,6 @@ def api_decide_human(run_id: str, body: HumanDecisionRequest, db=_DB, current_us # @RELATION DEPENDS_ON -> [ScenarioExecution.Runner.QueuedDispatch] # @INVARIANT Approval CAS can expose an approved run as queued, but only the separate server # dispatcher may claim and execute its initial frontier. -# @TEST_EDGE approve -> queued; deny -> blocked; already_decided -> 409; missing_gate -> 404; -# missing_run_prod_scope -> 403; invalid_decision -> 422. @runs_router.post("/{run_id}/approval/decision") def api_decide_approval_gate(run_id: str, body: ApprovalDecisionRequest, db=_DB, current_user=_USER, _=_RUN_PROD_PERMISSION): gate = ( diff --git a/backend/src/api/routes/dashboard_testing/scenarios.py b/backend/src/api/routes/dashboard_testing/scenarios.py index 82b3cbcf5..2a43f51df 100644 --- a/backend/src/api/routes/dashboard_testing/scenarios.py +++ b/backend/src/api/routes/dashboard_testing/scenarios.py @@ -111,7 +111,6 @@ def api_list_scenarios( # @BRIEF Atomically register a validated draft pack as a candidate revision. # @PRE Caller has dashboard:testing WRITE; handles identify server-owned artifacts. # @POST Returns scenario/revision ids with materialization_status=materialized; failures roll back both rows. -# @TEST_EDGE digest_mismatch -> 409; missing_runner_plan -> 409; access_denied -> 409. @router.post("", response_model=ScenarioCreateResponse, status_code=status.HTTP_201_CREATED) def api_create_scenario( body: ScenarioCreateRequest, @@ -505,7 +504,6 @@ def api_revalidate_scenario( # @BRIEF Load a scenario detail by id (operationId scenarioRegistry.detail; fixes getScenarioDraft 404). # @PRE Caller has dashboard:testing READ. # @POST Returns ScenarioDetailResponse; 404 NOT_FOUND when the scenario is not in the registry. -# @TEST_EDGE not_found -> 404 with {"code": "NOT_FOUND"} envelope. @router.get("/{scenario_id}", response_model=ScenarioDetailResponse) def api_get_scenario( scenario_id: str, diff --git a/backend/src/api/routes/dashboard_testing/structure.py b/backend/src/api/routes/dashboard_testing/structure.py index 0a9e11e3d..82fd4695d 100644 --- a/backend/src/api/routes/dashboard_testing/structure.py +++ b/backend/src/api/routes/dashboard_testing/structure.py @@ -1,4 +1,4 @@ -#region Api.DashboardTesting.StructureDiff [C:3] [TYPE Module] [SEMANTICS baseline,api,structure-diff,feature-037] +# #region Api.DashboardTesting.StructureDiff [C:3] [TYPE Module] [SEMANTICS baseline,api,structure-diff,feature-037] # @defgroup Api Structure diff API route — POST /dashboard-testing/structure-diff. # @LAYER API # @RELATION DEPENDS_ON -> [BaselineEngine.StructureDiff.Service] @@ -51,4 +51,4 @@ async def compute_structure_diff_endpoint( # #endregion Api.DashboardTesting.ComputeStructureDiff -#endregion Api.DashboardTesting.StructureDiff +# #endregion Api.DashboardTesting.StructureDiff diff --git a/backend/src/api/routes/dashboard_testing/structure_snapshot.py b/backend/src/api/routes/dashboard_testing/structure_snapshot.py index 92561e3f8..c3f7c799c 100644 --- a/backend/src/api/routes/dashboard_testing/structure_snapshot.py +++ b/backend/src/api/routes/dashboard_testing/structure_snapshot.py @@ -1,4 +1,4 @@ -#region Api.DashboardTesting.StructureSnapshot [C:4] [TYPE Module] [SEMANTICS baseline,api,structure-snapshot,release-bound,capture,diff] +# #region Api.DashboardTesting.StructureSnapshot [C:4] [TYPE Module] [SEMANTICS baseline,api,structure-snapshot,release-bound,capture,diff] # @defgroup Api Release-bound structure snapshot API routes — capture + diff with identity validation. # @LAYER API # @RELATION DEPENDS_ON -> [BaselineEngine.StructureSnapshot.Service] @@ -45,6 +45,9 @@ _EXECUTE_PERMISSION = Depends(has_permission("dashboard:testing", "EXECUTE")) _DB_SESSION = Depends(get_db) +# #region Api.DashboardTesting.StructureSnapshot.ResolveEnvById [C:2] [TYPE Function] +# @ingroup Api +# @BRIEF Resolve a configured Environment by id — raises 404 if unknown. async def _resolve_env_by_id(environment_id: str) -> Environment: """Resolve a configured Environment by id — raises 404 if unknown.""" env = get_config_manager().get_environment(environment_id) @@ -54,6 +57,7 @@ async def _resolve_env_by_id(environment_id: str) -> Environment: detail=f"Environment '{environment_id}' not found.", ) return env +# #endregion Api.DashboardTesting.StructureSnapshot.ResolveEnvById # #region Api.DashboardTesting.CaptureReleaseSnapshot [C:5] [TYPE Function] [SEMANTICS baseline,api,capture,release-bound,provenance] @@ -254,4 +258,4 @@ async def diff_release_snapshots_endpoint( ) from err # #endregion Api.DashboardTesting.DiffReleaseSnapshots -#endregion Api.DashboardTesting.StructureSnapshot +# #endregion Api.DashboardTesting.StructureSnapshot diff --git a/backend/src/api/routes/dashboard_testing/verification.py b/backend/src/api/routes/dashboard_testing/verification.py index 2467a82a3..caa3d8e75 100644 --- a/backend/src/api/routes/dashboard_testing/verification.py +++ b/backend/src/api/routes/dashboard_testing/verification.py @@ -1,4 +1,4 @@ -#region Api.DashboardTesting.VerificationRuns [C:3] [TYPE Module] [SEMANTICS baseline,api,verification-runs,feature-037] +# #region Api.DashboardTesting.VerificationRuns [C:3] [TYPE Module] [SEMANTICS baseline,api,verification-runs,feature-037] # @defgroup Api Verification runs API route — POST create + GET history/detail (037 T081). # @LAYER API # @RELATION DEPENDS_ON -> [BaselineEngine.Verification.Service] @@ -71,8 +71,6 @@ async def create_verification_run_endpoint( # @BRIEF List verification runs chronologically, optionally filtered by dashboard_id/environment_id (037 T081). # @POST Returns VerificationRun[] ordered by created_at desc; matches frontend getVerificationHistory(). # @RELATION CALLS -> [BaselineEngine.Verification.RecordToResponse] -# @TEST_EDGE dashboard_filter -> only runs for that dashboard returned. -# @TEST_EDGE env_filter -> only runs for that environment returned. @router.get("/verification/history", response_model=list[VerificationRun]) def list_verification_history( dashboard_id: int | None = None, @@ -96,7 +94,6 @@ def list_verification_history( # @BRIEF Return a single verification run by id (037 T081). # @POST Returns VerificationRun; 404 when the run does not exist. # @RELATION CALLS -> [BaselineEngine.Verification.RecordToResponse] -# @TEST_EDGE missing_run -> 404. @router.get("/verification/{run_id}", response_model=VerificationRun) def get_verification_run( run_id: str, @@ -109,4 +106,4 @@ def get_verification_run( return _record_to_response(record) # #endregion Api.DashboardTesting.VerificationDetail -#endregion Api.DashboardTesting.VerificationRuns +# #endregion Api.DashboardTesting.VerificationRuns diff --git a/backend/src/api/routes/dashboards/__init__.py b/backend/src/api/routes/dashboards/__init__.py index 004e659c7..8ed9e2132 100644 --- a/backend/src/api/routes/dashboards/__init__.py +++ b/backend/src/api/routes/dashboards/__init__.py @@ -14,25 +14,6 @@ # @SIDE_EFFECT Performs external calls to Superset API and potentially Git providers. # @DATA_CONTRACT Input(env_id, filters) -> Output(DashboardsResponse) # -# @TEST_CONTRACT DashboardsAPI -> { -# required_fields: {env_id: string, page: integer, page_size: integer}, -# optional_fields: {search: string}, -# invariants: ["Pagination must be valid", "Environment must exist"] -# } -# -# @TEST_FIXTURE dashboard_list_happy -> { -# "env_id": "prod", -# "expected_count": 1, -# "dashboards": [{"id": 1, "title": "Main Revenue"}] -# } -# -# @TEST_EDGE pagination_zero_page -> {"env_id": "prod", "page": 0, "status": 400} -# @TEST_EDGE pagination_oversize -> {"env_id": "prod", "page_size": 101, "status": 400} -# @TEST_EDGE missing_env -> {"env_id": "ghost", "status": 404} -# @TEST_EDGE empty_dashboards -> {"env_id": "empty_env", "expected_total": 0} -# @TEST_EDGE external_superset_failure -> {"env_id": "bad_conn", "status": 503} -# -# @TEST_INVARIANT metadata_consistency -> verifies: [dashboard_list_happy, empty_dashboards] from ._action_routes import * from ._detail_routes import * diff --git a/backend/src/api/routes/encryption_health.py b/backend/src/api/routes/encryption_health.py index febcfc8dc..420fba03c 100644 --- a/backend/src/api/routes/encryption_health.py +++ b/backend/src/api/routes/encryption_health.py @@ -11,10 +11,6 @@ # @RATIONALE Centralizes secret inventory and recovery after ENCRYPTION_KEY change. # Without this, operators must manually trace decrypt failures across # scattered API responses. -# @TEST_EDGE: empty_payload — POST /recover with empty items list → returns failed/failed -# @TEST_EDGE: provider_not_found — recovery for non-existent provider ID → skipped -# @TEST_EDGE: connection_not_found — recovery for non-existent connection ID → skipped -# @TEST_EDGE: invalid_type — recovery item with unknown type → skipped import hashlib diff --git a/backend/src/api/routes/git/_helpers.py b/backend/src/api/routes/git/_helpers.py index 1400ca36f..109037089 100644 --- a/backend/src/api/routes/git/_helpers.py +++ b/backend/src/api/routes/git/_helpers.py @@ -127,7 +127,7 @@ def _get_git_config_or_404(db: Session, config_id: str) -> GitServerConfig: # #endregion Api.Helpers.GetGitConfigOr404 -# #region Api.Helpers.FindDashboardIdBySlug [C:2] [TYPE Function] +# #region Api.Helpers.GitHelpers.FindDashboardIdBySlug [C:2] [TYPE Function] # @BRIEF Resolve dashboard numeric ID by slug in a specific environment. async def _find_dashboard_id_by_slug( client: SupersetClient, @@ -158,10 +158,10 @@ async def _find_dashboard_id_by_slug( return None -# #endregion Api.Helpers.FindDashboardIdBySlug +# #endregion Api.Helpers.GitHelpers.FindDashboardIdBySlug -# #region Api.Helpers.ResolveDashboardIdFromRef [C:2] [TYPE Function] +# #region Api.Helpers.GitHelpers.ResolveDashboardIdFromRef [C:2] [TYPE Function] # @BRIEF Resolve dashboard ID from slug-or-id reference for Git routes. async def _resolve_dashboard_id_from_ref( dashboard_ref: str, @@ -192,10 +192,10 @@ async def _resolve_dashboard_id_from_ref( return dashboard_id -# #endregion Api.Helpers.ResolveDashboardIdFromRef +# #endregion Api.Helpers.GitHelpers.ResolveDashboardIdFromRef -# #region Api.Helpers.FindDashboardIdBySlugAsync [C:2] [TYPE Function] +# #region Api.Helpers.GitHelpers.FindDashboardIdBySlugAsync [C:2] [TYPE Function] # @BRIEF Resolve dashboard numeric ID by slug asynchronously for hot-path Git routes. async def _find_dashboard_id_by_slug_async( client: "AsyncSupersetClient", @@ -226,10 +226,10 @@ async def _find_dashboard_id_by_slug_async( return None -# #endregion Api.Helpers.FindDashboardIdBySlugAsync +# #endregion Api.Helpers.GitHelpers.FindDashboardIdBySlugAsync -# #region Api.Helpers.ResolveDashboardIdFromRefAsync [C:2] [TYPE Function] +# #region Api.Helpers.GitHelpers.ResolveDashboardIdFromRefAsync [C:2] [TYPE Function] # @BRIEF Resolve dashboard ID asynchronously from slug-or-id reference for hot Git routes. async def _resolve_dashboard_id_from_ref_async( dashboard_ref: str, @@ -266,7 +266,7 @@ async def _resolve_dashboard_id_from_ref_async( await client.aclose() -# #endregion Api.Helpers.ResolveDashboardIdFromRefAsync +# #endregion Api.Helpers.GitHelpers.ResolveDashboardIdFromRefAsync # #region Api.Helpers.ResolveRepoKeyFromRef [C:2] [TYPE Function] diff --git a/backend/src/api/routes/migration.py b/backend/src/api/routes/migration.py index 6a60f1f9c..96246e7c0 100644 --- a/backend/src/api/routes/migration.py +++ b/backend/src/api/routes/migration.py @@ -14,13 +14,6 @@ # @POST Migration tasks are enqueued or dry-run results are computed and returned. # @SIDE_EFFECT Enqueues long-running tasks, potentially mutates ResourceMapping table, and performs remote Superset API calls. # @DATA_CONTRACT [DashboardSelection | QueryParams] -> [TaskResponse | DryRunResult | MappingSummary] -# @TEST_CONTRACT [DashboardSelection + configured envs] -> [task_id | dry-run result | sync summary] -# @TEST_SCENARIO [invalid_environment] -> [HTTP_400_or_404] -# @TEST_SCENARIO [valid_execution] -> [success_payload_with_required_fields] -# @TEST_EDGE [missing_field] ->[HTTP_400] -# @TEST_EDGE [invalid_type] ->[validation_error] -# @TEST_EDGE [external_fail] ->[HTTP_500] -# @TEST_INVARIANT [EnvironmentValidationBeforeAction] -> VERIFIED_BY: [invalid_environment, valid_execution] # @RATIONALE Separates API concerns (routing, permission checks, request/response serialization) from core migration business logic, enabling independent evolution of HTTP contract and domain logic. # @REJECTED Embedding API logic directly in the core migration layer was rejected — it would couple HTTP concerns with business logic, making both layers harder to test, version, and maintain independently. diff --git a/backend/src/api/routes/reports.py b/backend/src/api/routes/reports.py index 282641868..e43f1f20d 100644 --- a/backend/src/api/routes/reports.py +++ b/backend/src/api/routes/reports.py @@ -84,19 +84,6 @@ def _parse_csv_enum_list(raw: str | None, enum_cls, field_name: str) -> list: # @RELATION DEPENDS_ON -> [Models.Report.ReportQuery] # @RELATION DEPENDS_ON -> [Services.ReportService.ReportsService] # -# @TEST_CONTRACT ListReportsApi -> -# { -# required_fields: {page: int, page_size: int, sort_by: str, sort_order: str}, -# optional_fields: {task_types: str, statuses: str, search: str}, -# invariants: [ -# "Returns ReportCollection on success", -# "Raises HTTPException 400 for invalid query parameters" -# ] -# } -# @TEST_FIXTURE valid_list_request -> {"page": 1, "page_size": 20} -# @TEST_EDGE invalid_task_type_filter -> raises HTTPException(400) -# @TEST_EDGE malformed_query -> raises HTTPException(400) -# @TEST_INVARIANT consistent_list_payload -> verifies: [valid_list_request] @router.get("", response_model=ReportCollection) async def list_reports( page: int = Query(1, ge=1), diff --git a/backend/src/api/routes/tasks.py b/backend/src/api/routes/tasks.py index b8cefdb2d..045aa745a 100755 --- a/backend/src/api/routes/tasks.py +++ b/backend/src/api/routes/tasks.py @@ -231,13 +231,6 @@ async def get_task( # @POST Returns a list of log entries or raises 404. # @RELATION CALLS -> [Core.Manager.TaskManager] # @RELATION DEPENDS_ON -> [Core.Models.LogFilter] -# @TEST_CONTRACT TaskLogQueryInput -> List[LogEntry] -# @TEST_SCENARIO existing_task_logs_filtered -> Returns filtered logs by level/source/search with pagination. -# @TEST_FIXTURE valid_task_with_mixed_logs -> backend/tests/fixtures/task_logs/valid_task_with_mixed_logs.json -# @TEST_EDGE missing_task -> Unknown task_id returns 404 Task not found. -# @TEST_EDGE invalid_level_type -> Non-string/invalid level query rejected by validation or yields empty result. -# @TEST_EDGE pagination_bounds -> offset=0 and limit=1000 remain within API bounds and do not overflow. -# @TEST_INVARIANT logs_only_for_existing_task -> VERIFIED_BY: [existing_task_logs_filtered, missing_task] @router.get("/{task_id}/logs") async def get_task_logs( task_id: str, diff --git a/backend/src/app.py b/backend/src/app.py index b8adf8876..9cccf4205 100755 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -490,14 +490,25 @@ _POLLING_EXACT_PATHS = frozenset({ "/api/agent/llm-config", "/api/auth/session/activity", "/api/settings/consolidated", + "/api/environments", + "/api/auth/me", + "/api/auth/session", + "/api/settings/features", + "/api/settings/allowed-languages", + "/api/profile/preferences", + "/api/dashboards", + "/api/maintenance/dashboard-banners", + "/api/maintenance/events", + "/api/validation-tasks/status/batch", + "/api/git/repositories/status/batch", }) def _is_suppressed_request(request: Request) -> bool: """Return True for high-frequency polling requests (framing suppressed).""" path = request.url.path - return path in _POLLING_EXACT_PATHS or ( - request.method == "GET" and path.startswith("/api/tasks") + return request.method == "GET" and ( + path in _POLLING_EXACT_PATHS or path.startswith("/api/tasks") ) @@ -884,22 +895,6 @@ def _set_websocket_trace_id(websocket: WebSocket) -> str: # — sends unnecessary data over the network when the server can discard early. Polling # /api/tasks for status changes was rejected — introduces latency and load. # -# @TEST_CONTRACT WebSocketLogStreamApi -> -# { -# required_fields: {websocket: WebSocket, task_id: str}, -# optional_fields: {source: str, level: str}, -# invariants: [ -# "Accepts the WebSocket connection", -# "Applies source and level filters correctly to streamed logs", -# "Cleans up subscriptions on disconnect" -# ] -# } -# @TEST_FIXTURE valid_ws_connection -> {"task_id": "test_1", "source": "plugin"} -# @TEST_EDGE task_not_found_ws -> closes connection or sends error -# @TEST_EDGE empty_task_logs -> waits for new logs -# @TEST_INVARIANT consistent_streaming -> verifies: [valid_ws_connection] -# @TEST_EDGE ws_auth_missing_token -> connection rejected with 4001 -# @TEST_EDGE ws_auth_invalid_token -> connection rejected with 4001 @app.websocket("/ws/logs/{task_id}") async def websocket_endpoint(websocket: WebSocket, task_id: str, source: str = None, level: str = None): """ diff --git a/backend/src/core/async_job_runner.py b/backend/src/core/async_job_runner.py index ca9876d6a..ca40178cd 100644 --- a/backend/src/core/async_job_runner.py +++ b/backend/src/core/async_job_runner.py @@ -17,7 +17,7 @@ import asyncio from typing import Any -# #region Core.AsyncJobRunner [C:4] [TYPE Class] [SEMANTICS async,runner,scheduler] +# #region Core.AsyncJobRunner.Class [C:4] [TYPE Class] [SEMANTICS async,runner,scheduler] # @ingroup Core # @BRIEF Centralized async/sync bridge for dispatching coroutines from sync threads. # @PRE Event loop is captured at construction time. @@ -126,5 +126,5 @@ class AsyncJobRunner: error=traceback.format_exc()) asyncio.run_coroutine_threadsafe(_guarded(), self.loop) # #endregion Core.AsyncJobRunner.Dispatch -# #endregion Core.AsyncJobRunner +# #endregion Core.AsyncJobRunner.Class # #endregion Core.AsyncJobRunner diff --git a/backend/src/core/auth/jwt.py b/backend/src/core/auth/jwt.py index 4a3ddb986..fc8e81c4d 100644 --- a/backend/src/core/auth/jwt.py +++ b/backend/src/core/auth/jwt.py @@ -129,7 +129,6 @@ def _prune_blacklist(db: "Session") -> None: # @SIDE_EFFECT Writes to token_blacklist table; prunes expired entries. # @RELATION DEPENDS_ON -> [Models.Auth.TokenBlacklist] # @RELATION CALLS -> [Auth.Jwt._HashToken] -# @TEST_EDGE: already_expired -> skips blacklisting def blacklist_token(token: str, db: "Session") -> None: from sqlalchemy.orm import Session # noqa: F811 from ...models.auth import TokenBlacklist diff --git a/backend/src/core/config_manager.py b/backend/src/core/config_manager.py index 607ccfba0..4e127d828 100644 --- a/backend/src/core/config_manager.py +++ b/backend/src/core/config_manager.py @@ -40,7 +40,7 @@ from .rate_limiter import invalidate_rate_limiter_config _AUTH_POLICY_FIELDS = ("auth_max_attempts", "auth_attempt_window", "auth_ban_duration") -# #region Core.ConfigManager [C:5] [TYPE Class] +# #region Core.ConfigManager.Class [C:5] [TYPE Class] # @defgroup Core Module group. # @BRIEF Handles application configuration load, validation, mutation, and persistence lifecycle. # @PRE Database is accessible and AppConfigRecord schema is loaded. @@ -638,5 +638,5 @@ class ConfigManager: self.save() return True # #endregion Core.ConfigManager.DeleteEnvironment -# #endregion Core.ConfigManager +# #endregion Core.ConfigManager.Class # #endregion Core.ConfigManager diff --git a/backend/src/core/connection_service.py b/backend/src/core/connection_service.py index 0216b488a..a4dc48486 100644 --- a/backend/src/core/connection_service.py +++ b/backend/src/core/connection_service.py @@ -75,7 +75,7 @@ def _validate_name_uniqueness( # #endregion Core.ConnectionService.ValidateNameUniqueness -# #region Core.ConnectionService [C:3] [TYPE Class] [SEMANTICS settings,connections,service] +# #region Core.ConnectionService.Class [C:3] [TYPE Class] [SEMANTICS settings,connections,service] # @ingroup Core # @BRIEF Service class wrapping all DatabaseConnection CRUD + test operations. class ConnectionService: @@ -522,5 +522,5 @@ class ConnectionService: # #endregion Core.ConnectionService.ValidateConnectionRefs -# #endregion Core.ConnectionService +# #endregion Core.ConnectionService.Class # #endregion Core.ConnectionService diff --git a/backend/src/core/encryption.py b/backend/src/core/encryption.py index 3e51eb91b..7d0cc23c1 100644 --- a/backend/src/core/encryption.py +++ b/backend/src/core/encryption.py @@ -76,17 +76,6 @@ def _require_fernet_key() -> bytes: # run — cached at module level via get_encryption_manager() singleton to eliminate # redundant env-var reads and key validation. # -# @TEST_CONTRACT EncryptionManagerModel -> -# { -# required_fields: {}, -# invariants: [ -# "encrypted data can be decrypted back to the original string" -# ] -# } -# @TEST_FIXTURE basic_encryption_cycle -> {"data": "my_secret_key"} -# @TEST_EDGE decrypt_invalid_data -> raises Exception -# @TEST_EDGE empty_string_encryption -> {"data": ""} -# @TEST_INVARIANT symmetric_encryption -> verifies: [basic_encryption_cycle, empty_string_encryption] class EncryptionManager: # region Core.Encryption.EncryptionManagerInit [TYPE Function] # @BRIEF: Initialize the encryption manager with a Fernet key. diff --git a/backend/src/core/mapping_service.py b/backend/src/core/mapping_service.py index 6211229e0..6905e7047 100644 --- a/backend/src/core/mapping_service.py +++ b/backend/src/core/mapping_service.py @@ -10,8 +10,6 @@ # @POST Mapping synchronization and lookup APIs are available for environment-scoped UUID-to-integer resolution. # @SIDE_EFFECT Reads/writes ResourceMapping rows, emits logs. # @DATA_CONTRACT Input[environment_id, resource_type, uuid] -> Output[remote_integer_id|None] -# @TEST_DATA: mock_superset_resources -> {'chart': [{'id': 42, 'uuid': '1234', 'slice_name': 'test'}], 'dataset': [{'id': 99, 'uuid': '5678', 'table_name': 'data'}]} -# # @INVARIANT sync_environment must handle remote API failures gracefully. # @RATIONALE Centralizes UUID-to-integer ID resolution for Superset resources because the Superset API uses different ID schemes across endpoints (UUIDs for import/export, integer IDs for CRUD operations), enabling cross-environment migration with consistent resource references. # @REJECTED BackgroundScheduler — was never started; replaced by AsyncJobRunner for async/sync bridge. @@ -36,20 +34,6 @@ from src.models.mapping import Environment, ResourceMapping, ResourceType # @SIDE_EFFECT Performs database writes during sync cycles. # @DATA_CONTRACT Input[db_session] -> Output[IdMappingService] # -# @TEST_CONTRACT IdMappingServiceModel -> -# { -# required_fields: {db_session: Session}, -# invariants: [ -# "sync_environment correctly creates or updates ResourceMapping records", -# "get_remote_id returns an integer or None", -# "get_remote_ids_batch returns a dictionary of valid UUIDs to integers" -# ] -# } -# @TEST_FIXTURE valid_mapping_service -> {"db_session": "MockSession()"} -# @TEST_EDGE sync_api_failure -> handles exception gracefully -# @TEST_EDGE get_remote_id_not_found -> returns None -# @TEST_EDGE get_batch_empty_list -> returns empty dict -# @TEST_INVARIANT resilient_fetching -> verifies: [sync_api_failure] class IdMappingService: # #region Core.MappingService.Init [C:2] [TYPE Function] # @BRIEF: Initializes the mapping service. diff --git a/backend/src/core/migration/risk_assessor.py b/backend/src/core/migration/risk_assessor.py index 5f295affd..496c0c990 100644 --- a/backend/src/core/migration/risk_assessor.py +++ b/backend/src/core/migration/risk_assessor.py @@ -14,14 +14,6 @@ # @POST Risk scoring output preserves item list and provides bounded score with derived level. # @SIDE_EFFECT Emits diagnostic logs and performs read-only metadata requests via Superset client. # @DATA_CONTRACT Module[build_risks, score_risks] -# @TEST_CONTRACT [source_objects,target_objects,diff,target_client] -> [List[RiskItem]] -# @TEST_SCENARIO [overwrite_update_objects] -> [confirmation overwrite_existing item is emitted for each update diff item without increasing risk score] -# @TEST_SCENARIO [missing_datasource_dataset] -> [high missing_datasource risk is emitted] -# @TEST_SCENARIO [owner_mismatch_dashboard] -> [low owner_mismatch risk is emitted] -# @TEST_EDGE [missing_field] -> [object without uuid is ignored by indexer] -# @TEST_EDGE [invalid_type] -> [non-list owners input normalizes to empty identifiers] -# @TEST_EDGE [external_fail] -> [target_client get_databases exception propagates to caller] -# @TEST_INVARIANT [score_upper_bound_100] -> VERIFIED_BY: [severity_weight_aggregation] # @UX_STATE [Idle] -> [N/A backend domain module] # @UX_FEEDBACK [N/A] -> [No direct UI side effects in this module] # @UX_RECOVERY [N/A] -> [Caller-level retry/recovery] diff --git a/backend/src/core/superset_client/_audit.py b/backend/src/core/superset_client/_audit.py index b436bed7a..3e01d5cef 100644 --- a/backend/src/core/superset_client/_audit.py +++ b/backend/src/core/superset_client/_audit.py @@ -16,7 +16,7 @@ from ..logger import belief_scope, logger as app_logger app_logger = cast(Any, app_logger) -# #region Core.Audit.SupersetAuditMixin [C:4] [TYPE Class] +# #region Core.Audit.SupersetAuditMixin.Class [C:4] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing comprehensive permissions audit across users, dashboards, datasets, and RLS. # @RELATION DEPENDS_ON -> [Core.Base.SupersetClientBase] @@ -354,5 +354,5 @@ class SupersetAuditMixin: return found # #endregion SupersetAudit.FindDatasourcePermissions -# #endregion Core.Audit.SupersetAuditMixin +# #endregion Core.Audit.SupersetAuditMixin.Class # #endregion Core.Audit.SupersetAuditMixin diff --git a/backend/src/core/superset_client/_base.py b/backend/src/core/superset_client/_base.py index d46c5d952..33dd950eb 100644 --- a/backend/src/core/superset_client/_base.py +++ b/backend/src/core/superset_client/_base.py @@ -23,7 +23,7 @@ from ..utils.async_network import AsyncAPIClient from ..utils.network import SupersetAPIError app_logger = cast(Any, app_logger) -# #region Core.Base.SupersetClientBase [C:4] [TYPE Class] +# #region Core.Base.SupersetClientBase.Class [C:4] [TYPE Class] # @defgroup Core Module group. # @BRIEF Base class providing Superset client initialization, auth, pagination, and import/export plumbing. # @RELATION DEPENDS_ON -> [Core.ConfigModels] @@ -300,5 +300,5 @@ class SupersetClientBase: async def aclose(self) -> None: await self.client.aclose() # #endregion Core.Base.Aclose -# #endregion Core.Base.SupersetClientBase +# #endregion Core.Base.SupersetClientBase.Class # #endregion Core.Base.SupersetClientBase diff --git a/backend/src/core/superset_client/_chart_data.py b/backend/src/core/superset_client/_chart_data.py index a3db49e2d..c24707b8a 100644 --- a/backend/src/core/superset_client/_chart_data.py +++ b/backend/src/core/superset_client/_chart_data.py @@ -1,4 +1,4 @@ -#region SupersetClient.ChartData.Execute [C:4] [TYPE Module] [SEMANTICS baseline,superset,chart-data,async] +# #region SupersetClient.ChartData.Execute [C:4] [TYPE Module] [SEMANTICS baseline,superset,chart-data,async] # @defgroup Core Superset chart-data POST /api/v1/chart/data mixin. # @LAYER Infrastructure # @RELATION DEPENDS_ON -> [Core.Base.SupersetClientBase] @@ -30,13 +30,13 @@ class ChartDataResponse: source_response_hash: str = field(repr=False) # #endregion SupersetClient.ChartData.ResponseDTO -# @region SupersetClient.ChartData.SupersetChartDataMixin [C:4] [TYPE Class] +# #region SupersetClient.ChartData.SupersetChartDataMixin [C:4] [TYPE Class] # @defgroup Core Chart data execution mixin for SupersetClient. # @SIDE_EFFECT Async POST to Superset /api/v1/chart/data. class SupersetChartDataMixin: """Mixin for executing Superset chart-data queries.""" - # @region SupersetClient.ChartData.ExecuteQueryRaw [C:4] [TYPE Function] [SEMANTICS chart-data,raw,response,hash] + # #region SupersetClient.ChartData.ExecuteQueryRaw [C:4] [TYPE Function] [SEMANTICS chart-data,raw,response,hash] # @ingroup Core # @BRIEF Execute chart-data query with raw_response=True; return ChartDataResponse with raw bytes + hash. # @PRE Saved chart exists and is accessible. @@ -114,9 +114,9 @@ class SupersetChartDataMixin: except Exception as e: app_logger.explore("Chart data raw execution failed", extra={"chart_id": chart_id}, error=str(e)) raise SupersetAPIError(f"Chart data raw execution failed: {e}") from e - # @endregion SupersetClient.ChartData.ExecuteQueryRaw + # #endregion SupersetClient.ChartData.ExecuteQueryRaw - # @region SupersetClient.ChartData.ExecuteQuery [C:4] [TYPE Function] + # #region SupersetClient.ChartData.ExecuteQuery [C:4] [TYPE Function] # @ingroup Core # @BRIEF Backward-compatible wrapper — execute chart-data query, return only the parsed dict. # @PRE Saved chart exists and is accessible. @@ -150,9 +150,9 @@ class SupersetChartDataMixin: row_limit=row_limit, ) return response.parsed - # @endregion SupersetClient.ChartData.ExecuteQuery + # #endregion SupersetClient.ChartData.ExecuteQuery - # @region SupersetClient.ChartData.BuildAdhocFilters [C:2] [TYPE Function] [SEMANTICS superse,chart-data,filters] + # #region SupersetClient.ChartData.BuildAdhocFilters [C:2] [TYPE Function] [SEMANTICS superse,chart-data,filters] # @ingroup Core # @BRIEF Normalize filter dicts into Superset adhoc filter format, preserving TEMPORAL_RANGE. # @PRE Input filters use subject/comparator/expressionType keys. @@ -191,8 +191,8 @@ class SupersetChartDataMixin: entry["to"] = to_val adhoc_filters.append(entry) return adhoc_filters - # @endregion SupersetClient.ChartData.BuildAdhocFilters + # #endregion SupersetClient.ChartData.BuildAdhocFilters -# @endregion SupersetClient.ChartData.SupersetChartDataMixin +# #endregion SupersetClient.ChartData.SupersetChartDataMixin -#endregion SupersetClient.ChartData.Execute +# #endregion SupersetClient.ChartData.Execute diff --git a/backend/src/core/superset_client/_charts.py b/backend/src/core/superset_client/_charts.py index 8bc82410c..049d72d2c 100644 --- a/backend/src/core/superset_client/_charts.py +++ b/backend/src/core/superset_client/_charts.py @@ -10,7 +10,7 @@ from typing import Any, cast from ..logger import belief_scope, logger as app_logger app_logger = cast(Any, app_logger) -# #region Core.Charts.SupersetChartsMixin [C:3] [TYPE Class] +# #region Core.Charts.SupersetChartsMixin.Class [C:3] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing all chart-related Superset API operations. # @RELATION DEPENDS_ON -> [Core.Base.SupersetClientBase] @@ -74,5 +74,5 @@ class SupersetChartsMixin: walk(payload) return found # #endregion Core.Charts.SupersetClientExtractChartIdsFromLayout -# #endregion Core.Charts.SupersetChartsMixin +# #endregion Core.Charts.SupersetChartsMixin.Class # #endregion Core.Charts.SupersetChartsMixin diff --git a/backend/src/core/superset_client/_dashboards_crud.py b/backend/src/core/superset_client/_dashboards_crud.py index f66ffc824..72609ecdf 100644 --- a/backend/src/core/superset_client/_dashboards_crud.py +++ b/backend/src/core/superset_client/_dashboards_crud.py @@ -16,7 +16,7 @@ import httpx from ..logger import belief_scope, logger as app_logger app_logger = cast(Any, app_logger) -# #region Core.DashboardsCrud.SupersetDashboardsCrudMixin [C:3] [TYPE Class] +# #region Core.DashboardsCrud.SupersetDashboardsCrudMixin.Class [C:3] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing dashboard detail resolution, export, import, and delete operations. # @RELATION DEPENDS_ON -> [Core.Base.SupersetClientBase] @@ -430,5 +430,5 @@ class SupersetDashboardsCrudMixin: raise # #endregion Core.DashboardsCrud.SupersetClientGetDashboard -# #endregion Core.DashboardsCrud.SupersetDashboardsCrudMixin +# #endregion Core.DashboardsCrud.SupersetDashboardsCrudMixin.Class # #endregion Core.DashboardsCrud.SupersetDashboardsCrudMixin diff --git a/backend/src/core/superset_client/_dashboards_filters.py b/backend/src/core/superset_client/_dashboards_filters.py index cacaea05b..8f8d26e3a 100644 --- a/backend/src/core/superset_client/_dashboards_filters.py +++ b/backend/src/core/superset_client/_dashboards_filters.py @@ -10,7 +10,7 @@ from typing import Any, cast from ..logger import belief_scope, logger as app_logger app_logger = cast(Any, app_logger) -# #region Core.DashboardsFilters.SupersetDashboardsFiltersMixin [C:3] [TYPE Class] +# #region Core.DashboardsFilters.SupersetDashboardsFiltersMixin.Class [C:3] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing dashboard native filter extraction from permalink and URL state. # @RELATION DEPENDS_ON -> [Core.Base.SupersetClientBase] @@ -236,5 +236,5 @@ class SupersetDashboardsFiltersMixin: ) return result # #endregion Core.DashboardsFilters.SupersetClientParseDashboardUrlForFilters -# #endregion Core.DashboardsFilters.SupersetDashboardsFiltersMixin +# #endregion Core.DashboardsFilters.SupersetDashboardsFiltersMixin.Class # #endregion Core.DashboardsFilters.SupersetDashboardsFiltersMixin diff --git a/backend/src/core/superset_client/_dashboards_list.py b/backend/src/core/superset_client/_dashboards_list.py index 73ce66317..79d94872e 100644 --- a/backend/src/core/superset_client/_dashboards_list.py +++ b/backend/src/core/superset_client/_dashboards_list.py @@ -11,7 +11,7 @@ from typing import Any, cast from ..logger import belief_scope, logger as app_logger app_logger = cast(Any, app_logger) -# #region Core.DashboardsList.SupersetDashboardsListMixin [C:3] [TYPE Class] +# #region Core.DashboardsList.SupersetDashboardsListMixin.Class [C:3] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing dashboard listing and summary projection operations. # @RELATION DEPENDS_ON -> [Core.Base.SupersetClientBase] @@ -205,5 +205,5 @@ class SupersetDashboardsListMixin: }) return total_count, result # #endregion Core.DashboardsList.SupersetClientGetDashboardsSummaryPage -# #endregion Core.DashboardsList.SupersetDashboardsListMixin +# #endregion Core.DashboardsList.SupersetDashboardsListMixin.Class # #endregion Core.DashboardsList.SupersetDashboardsListMixin diff --git a/backend/src/core/superset_client/_dashboards_write.py b/backend/src/core/superset_client/_dashboards_write.py index 57442491b..f14719b68 100644 --- a/backend/src/core/superset_client/_dashboards_write.py +++ b/backend/src/core/superset_client/_dashboards_write.py @@ -26,7 +26,7 @@ from ._layout_utils import ( app_logger = cast(Any, app_logger) -# #region Core.DashboardsWrite.SupersetDashboardsWriteMixin [C:4] [TYPE Class] +# #region Core.DashboardsWrite.SupersetDashboardsWriteMixin.Class [C:4] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing markdown chart CRUD and dashboard layout manipulation for maintenance banners. # @RELATION DEPENDS_ON -> [Core.Base.SupersetClientBase] @@ -532,5 +532,5 @@ class SupersetDashboardsWriteMixin: raise # #endregion Core.DashboardsWrite.UnpublishDashboard -# #endregion Core.DashboardsWrite.SupersetDashboardsWriteMixin +# #endregion Core.DashboardsWrite.SupersetDashboardsWriteMixin.Class # #endregion Core.DashboardsWrite.SupersetDashboardsWriteMixin diff --git a/backend/src/core/superset_client/_databases.py b/backend/src/core/superset_client/_databases.py index 8c3f2c581..ad0e6ff2b 100644 --- a/backend/src/core/superset_client/_databases.py +++ b/backend/src/core/superset_client/_databases.py @@ -10,7 +10,7 @@ from typing import Any, cast from ..logger import belief_scope, logger as app_logger app_logger = cast(Any, app_logger) -# #region Core.Databases.SupersetDatabasesMixin [C:3] [TYPE Class] +# #region Core.Databases.SupersetDatabasesMixin.Class [C:3] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing all database-related Superset API operations. # @RELATION DEPENDS_ON -> [Core.Base.SupersetClientBase] @@ -462,5 +462,5 @@ class SupersetDatabasesMixin: raise # #endregion Core.Databases.SupersetClientGetAvailableEngines -# #endregion Core.Databases.SupersetDatabasesMixin +# #endregion Core.Databases.SupersetDatabasesMixin.Class # #endregion Core.Databases.SupersetDatabasesMixin diff --git a/backend/src/core/superset_client/_datasets_preview.py b/backend/src/core/superset_client/_datasets_preview.py index bf73b15b8..79d7f21b6 100644 --- a/backend/src/core/superset_client/_datasets_preview.py +++ b/backend/src/core/superset_client/_datasets_preview.py @@ -13,7 +13,7 @@ from ..logger import belief_scope, logger as app_logger from ..utils.network import SupersetAPIError -# #region Core.DatasetsPreview.SupersetDatasetsPreviewMixin [C:4] [TYPE Class] +# #region Core.DatasetsPreview.SupersetDatasetsPreviewMixin.Class [C:4] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing dataset preview compilation and query context building. # @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API. @@ -217,5 +217,5 @@ class SupersetDatasetsPreviewMixin: ) return payload # #endregion Core.DatasetsPreview.SupersetClientBuildDatasetPreviewQueryContext -# #endregion Core.DatasetsPreview.SupersetDatasetsPreviewMixin +# #endregion Core.DatasetsPreview.SupersetDatasetsPreviewMixin.Class # #endregion Core.DatasetsPreview.SupersetDatasetsPreviewMixin diff --git a/backend/src/core/superset_client/_datasets_preview_filters.py b/backend/src/core/superset_client/_datasets_preview_filters.py index dc2cd1c5a..61c1c15df 100644 --- a/backend/src/core/superset_client/_datasets_preview_filters.py +++ b/backend/src/core/superset_client/_datasets_preview_filters.py @@ -12,7 +12,7 @@ from ..logger import belief_scope, logger as app_logger from ..utils.network import SupersetAPIError -# #region Core.DatasetsPreviewFilters.SupersetDatasetsPreviewFiltersMixin [C:3] [TYPE Class] +# #region Core.DatasetsPreviewFilters.SupersetDatasetsPreviewFiltersMixin.Class [C:3] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing filter normalization and compiled-SQL extraction for dataset preview operations. # @RELATION DEPENDS_ON -> [Core.Base.SupersetClientBase] @@ -181,5 +181,5 @@ class SupersetDatasetsPreviewFiltersMixin: f"(diagnostics={response_diagnostics!r})" ) # #endregion Core.DatasetsPreviewFilters.SupersetClientExtractCompiledSqlFromPreviewResponse -# #endregion Core.DatasetsPreviewFilters.SupersetDatasetsPreviewFiltersMixin +# #endregion Core.DatasetsPreviewFilters.SupersetDatasetsPreviewFiltersMixin.Class # #endregion Core.DatasetsPreviewFilters.SupersetDatasetsPreviewFiltersMixin diff --git a/backend/src/core/superset_client/_saved_queries.py b/backend/src/core/superset_client/_saved_queries.py index 2391d77ed..739c57488 100644 --- a/backend/src/core/superset_client/_saved_queries.py +++ b/backend/src/core/superset_client/_saved_queries.py @@ -11,7 +11,7 @@ from ..logger import belief_scope, logger as app_logger app_logger = cast(Any, app_logger) -# #region Core.SavedQueries.SupersetSavedQueriesMixin [C:3] [TYPE Class] +# #region Core.SavedQueries.SupersetSavedQueriesMixin.Class [C:3] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing saved query CRUD operations. # @RELATION DEPENDS_ON -> [Core.Base.SupersetClientBase] @@ -156,5 +156,5 @@ class SupersetSavedQueriesMixin: raise # #endregion SupersetSavedQueries.Delete -# #endregion Core.SavedQueries.SupersetSavedQueriesMixin +# #endregion Core.SavedQueries.SupersetSavedQueriesMixin.Class # #endregion Core.SavedQueries.SupersetSavedQueriesMixin diff --git a/backend/src/core/superset_client/_sql_lab.py b/backend/src/core/superset_client/_sql_lab.py index 77d26f137..1f5b36ee8 100644 --- a/backend/src/core/superset_client/_sql_lab.py +++ b/backend/src/core/superset_client/_sql_lab.py @@ -14,7 +14,7 @@ from .safety import is_dangerous_sql app_logger = cast(Any, app_logger) -# #region Core.SqlLab.SupersetSqlLabMixin [C:4] [TYPE Class] +# #region Core.SqlLab.SupersetSqlLabMixin.Class [C:4] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing SQL Lab execution, formatting, results retrieval, cost estimation, CSV export, and query history. # @RELATION DEPENDS_ON -> [Core.Base.SupersetClientBase] @@ -261,5 +261,5 @@ class SupersetSqlLabMixin: raise # #endregion SupersetSqlLab.StopQuery -# #endregion Core.SqlLab.SupersetSqlLabMixin +# #endregion Core.SqlLab.SupersetSqlLabMixin.Class # #endregion Core.SqlLab.SupersetSqlLabMixin diff --git a/backend/src/core/task_manager/context.py b/backend/src/core/task_manager/context.py index 16644b413..ecef2f482 100644 --- a/backend/src/core/task_manager/context.py +++ b/backend/src/core/task_manager/context.py @@ -38,19 +38,6 @@ from .task_logger import TaskLogger # plugin signatures. A global singleton logger was rejected — it cannot scope log # entries to individual task executions when multiple tasks run concurrently. # -# @TEST_CONTRACT TaskContextContract -> -# { -# required_fields: {task_id: str, add_log_fn: Callable, params: dict}, -# optional_fields: {default_source: str}, -# invariants: [ -# "task_id matches initialized logger's task_id", -# "logger is a valid TaskLogger instance" -# ] -# } -# @TEST_FIXTURE valid_context -> {"task_id": "123", "add_log_fn": lambda *args: None, "params": {"k": "v"}, "default_source": "plugin"} -# @TEST_EDGE missing_task_id -> raises TypeError -# @TEST_EDGE missing_add_log_fn -> raises TypeError -# @TEST_INVARIANT logger_initialized -> verifies: [valid_context] class TaskContext: """ Execution context provided to plugins during task execution. diff --git a/backend/src/core/task_manager/manager.py b/backend/src/core/task_manager/manager.py index c5ca03714..d16fad5ce 100644 --- a/backend/src/core/task_manager/manager.py +++ b/backend/src/core/task_manager/manager.py @@ -15,17 +15,6 @@ # @RELATION DEPENDS_ON -> [Core.Lifecycle.JobLifecycle] # @RELATION DEPENDS_ON -> [Core.EventBus] # @INVARIANT Task IDs are unique. -# @TEST_CONTRACT TaskManagerRuntime -> { -# required_fields: {plugin_loader: PluginLoader}, -# optional_fields: {}, -# invariants: ["Must use belief_scope for logging"] -# } -# @TEST_FIXTURE valid_module -> {"manager_initialized": true} -# @TEST_EDGE missing_required_field -> {"plugin_loader": null} -# @TEST_EDGE empty_response -> {"tasks": []} -# @TEST_EDGE invalid_type -> {"plugin_loader": "string_instead_of_object"} -# @TEST_EDGE external_failure -> {"db_unavailable": true} -# @TEST_INVARIANT logger_compliance -> verifies: [valid_module] # @RATIONALE Decomposed from 708-line monolithic module into four focused modules (TaskGraph, # EventBus, JobLifecycle, and this facade) to satisfy INV_7. TaskManager now delegates # to sub-services while preserving the public API contract. @@ -73,7 +62,7 @@ class TaskManager: JobLifecycle (state machine) into a single TaskManager interface. """ - # #region Core.Manager.Init [C:4] [TYPE Function] [C:5] + # #region Core.Manager.Init [C:4] [TYPE Function] # @BRIEF Initialize sub-services, create add_log callback, start background flusher. # @PRE plugin_loader is initialized. # @POST TaskManager is ready to accept tasks. @@ -168,7 +157,7 @@ class TaskManager: # ── Task CRUD delegates to TaskGraph ── - # #region Core.Manager.GetTask [C:2] [TYPE Function] [C:2] + # #region Core.Manager.GetTask [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Retrieves a task by its ID. def get_task(self, task_id: str) -> Task | None: @@ -181,7 +170,7 @@ class TaskManager: return self.graph.get_all_tasks() # #endregion Core.Manager.GetAllTasks - # #region Core.Manager.GetTasks [C:3] [TYPE Function] [C:3] + # #region Core.Manager.GetTasks [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF Retrieves tasks with pagination and optional status/plugin/search filters. def get_tasks( @@ -196,14 +185,14 @@ class TaskManager: return self.graph.get_tasks(limit, offset, status, plugin_ids, completed_only, search) # #endregion Core.Manager.GetTasks - # #region Core.Manager.LoadPersistedTasks [C:2] [TYPE Function] [C:2] + # #region Core.Manager.LoadPersistedTasks [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Load persisted tasks using persistence service. def load_persisted_tasks(self) -> None: self.graph.load_persisted_tasks(limit=100) # #endregion Core.Manager.LoadPersistedTasks - # #region Core.Manager.ClearTasks [C:4] [TYPE Function] [C:4] + # #region Core.Manager.ClearTasks [C:4] [TYPE Function] # @ingroup TaskManager # @BRIEF Clears tasks based on status filter (also deletes associated logs). # @SIDE_EFFECT Removes tasks from registry and persistence; cancels futures. @@ -235,7 +224,7 @@ class TaskManager: # ── Log delegates to EventBus ── - # #region Core.Manager.GetTaskLogs [C:3] [TYPE Function] [C:3] + # #region Core.Manager.GetTaskLogs [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF Retrieves logs for a specific task (from memory or persistence). def get_task_logs( @@ -249,14 +238,14 @@ class TaskManager: ) # #endregion Core.Manager.GetTaskLogs - # #region Core.Manager.GetTaskLogStats [C:2] [TYPE Function] [C:2] + # #region Core.Manager.GetTaskLogStats [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Get statistics about logs for a task. def get_task_log_stats(self, task_id: str) -> LogStats: return self.event_bus.get_task_log_stats(task_id) # #endregion Core.Manager.GetTaskLogStats - # #region Core.Manager.GetTaskLogSources [C:2] [TYPE Function] [C:2] + # #region Core.Manager.GetTaskLogSources [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Get unique sources for a task's logs. def get_task_log_sources(self, task_id: str) -> list[str]: @@ -265,14 +254,14 @@ class TaskManager: # ── Subscription delegates to EventBus ── - # #region Core.Manager.SubscribeLogs [C:2] [TYPE Function] [C:2] + # #region Core.Manager.SubscribeLogs [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Subscribes to real-time logs for a task. async def subscribe_logs(self, task_id: str) -> asyncio.Queue: return await self.event_bus.subscribe_logs(task_id) # #endregion Core.Manager.SubscribeLogs - # #region Core.Manager.UnsubscribeLogs [C:2] [TYPE Function] [C:2] + # #region Core.Manager.UnsubscribeLogs [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Unsubscribes from real-time logs for a task. def unsubscribe_logs(self, task_id: str, queue: asyncio.Queue): @@ -281,28 +270,28 @@ class TaskManager: # ── Status subscribers ── - # #region Core.Manager.SubscribeStatus [C:2] [TYPE Function] [C:2] + # #region Core.Manager.SubscribeStatus [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Subscribes to real-time status updates for a task. async def subscribe_status(self, task_id: str) -> asyncio.Queue: return await self.event_bus.subscribe_status(task_id) # #endregion Core.Manager.SubscribeStatus - # #region Core.Manager.UnsubscribeStatus [C:2] [TYPE Function] [C:2] + # #region Core.Manager.UnsubscribeStatus [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Unsubscribes from status updates for a task. def unsubscribe_status(self, task_id: str, queue: asyncio.Queue): self.event_bus.unsubscribe_status(task_id, queue) # #endregion Core.Manager.UnsubscribeStatus - # #region Core.Manager.SubscribeTaskEvents [C:2] [TYPE Function] [C:2] + # #region Core.Manager.SubscribeTaskEvents [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Subscribes to global task events (all task status changes). async def subscribe_task_events(self) -> asyncio.Queue: return await self.event_bus.subscribe_task_events() # #endregion Core.Manager.SubscribeTaskEvents - # #region Core.Manager.UnsubscribeTaskEvents [C:2] [TYPE Function] [C:2] + # #region Core.Manager.UnsubscribeTaskEvents [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Unsubscribes from global task events. def unsubscribe_task_events(self, queue: asyncio.Queue): @@ -311,7 +300,7 @@ class TaskManager: # ── Lifecycle delegates to JobLifecycle ── - # #region Core.Manager.CreateTask [C:4] [TYPE Function] [C:4] + # #region Core.Manager.CreateTask [C:4] [TYPE Function] # @ingroup TaskManager # @BRIEF Creates and queues a new task for execution. async def create_task( @@ -330,7 +319,7 @@ class TaskManager: return task # #endregion Core.Manager.CreateTask - # #region Core.Manager.RunTask [C:4] [TYPE Function] [C:4] + # #region Core.Manager.RunTask [C:4] [TYPE Function] # @BRIEF Internal method to execute a task with TaskContext support (delegates to lifecycle). # Tracks the asyncio.Task for management/cancellation. async def _run_task(self, task_id: str): @@ -344,7 +333,7 @@ class TaskManager: self._async_tasks.pop(task_id, None) # #endregion Core.Manager.RunTask - # #region Core.Manager.CancelTask [C:3] [TYPE Function] [C:3] + # #region Core.Manager.CancelTask [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF Cancel a running task by ID. # @PRE Task must be currently tracked as running. @@ -358,28 +347,28 @@ class TaskManager: return True # #endregion Core.Manager.CancelTask - # #region Core.Manager.ResolveTask [C:3] [TYPE Function] [C:3] + # #region Core.Manager.ResolveTask [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF Resumes a task that is awaiting mapping. async def resolve_task(self, task_id: str, resolution_params: dict[str, Any]): await self.lifecycle.resolve_task(task_id, resolution_params) # #endregion Core.Manager.ResolveTask - # #region Core.Manager.WaitForResolution [C:3] [TYPE Function] [C:3] + # #region Core.Manager.WaitForResolution [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF Pauses execution and waits for a resolution signal. async def wait_for_resolution(self, task_id: str): await self.lifecycle.wait_for_resolution(task_id) # #endregion Core.Manager.WaitForResolution - # #region Core.Manager.WaitForInput [C:3] [TYPE Function] [C:3] + # #region Core.Manager.WaitForInput [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF Pauses execution and waits for user input. async def wait_for_input(self, task_id: str): await self.lifecycle.wait_for_input(task_id) # #endregion Core.Manager.WaitForInput - # #region Core.Manager.AwaitInput [C:3] [TYPE Function] [C:3] + # #region Core.Manager.AwaitInput [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF Transition a task to AWAITING_INPUT state with input request. async def await_input(self, task_id: str, input_request: dict[str, Any]) -> None: @@ -389,7 +378,7 @@ class TaskManager: ) # #endregion Core.Manager.AwaitInput - # #region Core.Manager.ResumeTaskWithPassword [C:3] [TYPE Function] [C:3] + # #region Core.Manager.ResumeTaskWithPassword [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF Resume a task that is awaiting input with provided passwords. async def resume_task_with_password( @@ -407,7 +396,7 @@ class TaskManager: ) # #endregion Core.Manager.ResumeTaskWithPassword - # #region Core.Manager.RetryTask [C:3] [TYPE Function] [C:3] + # #region Core.Manager.RetryTask [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF Retry a failed task by resetting state and re-queuing execution. async def retry_task(self, task_id: str) -> Task: @@ -426,21 +415,21 @@ class TaskManager: # ── Maintenance event delegates to EventBus ── - # #region Core.Manager.SubscribeMaintenanceEvents [C:2] [TYPE Function] [C:2] + # #region Core.Manager.SubscribeMaintenanceEvents [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Subscribes to global maintenance events. async def subscribe_maintenance_events(self) -> asyncio.Queue: return await self.event_bus.subscribe_maintenance_events() # #endregion Core.Manager.SubscribeMaintenanceEvents - # #region Core.Manager.UnsubscribeMaintenanceEvents [C:2] [TYPE Function] [C:2] + # #region Core.Manager.UnsubscribeMaintenanceEvents [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Unsubscribes from global maintenance events. def unsubscribe_maintenance_events(self, queue: asyncio.Queue): self.event_bus.unsubscribe_maintenance_events(queue) # #endregion Core.Manager.UnsubscribeMaintenanceEvents - # #region Core.Manager.BroadcastMaintenanceEvent [C:2] [TYPE Function] [C:2] + # #region Core.Manager.BroadcastMaintenanceEvent [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Broadcast a maintenance event to all subscribers. async def broadcast_maintenance_event(self, event: dict): @@ -449,14 +438,14 @@ class TaskManager: # ── Dataset event delegates to JobLifecycle ── - # #region Core.Manager.SubscribeDatasetEvents [C:2] [TYPE Function] [C:2] + # #region Core.Manager.SubscribeDatasetEvents [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Subscribe to dataset.updated events for an environment. async def subscribe_dataset_events(self, env_id: str) -> asyncio.Queue: return await self.lifecycle.subscribe_dataset_events(env_id) # #endregion Core.Manager.SubscribeDatasetEvents - # #region Core.Manager.UnsubscribeDatasetEvents [C:2] [TYPE Function] [C:2] + # #region Core.Manager.UnsubscribeDatasetEvents [C:2] [TYPE Function] # @ingroup TaskManager # @BRIEF Unsubscribe from dataset.updated events. def unsubscribe_dataset_events(self, env_id: str, queue: asyncio.Queue): diff --git a/backend/src/core/task_manager/persistence.py b/backend/src/core/task_manager/persistence.py index 2f286bd03..424751e27 100644 --- a/backend/src/core/task_manager/persistence.py +++ b/backend/src/core/task_manager/persistence.py @@ -45,19 +45,6 @@ from .models import LogEntry, LogFilter, LogStats, Task, TaskLog, TaskStatus # @RELATION DEPENDS_ON -> [Core.Graph.TaskGraph] # @INVARIANT Persistence must handle potentially missing task fields natively. # -# @TEST_CONTRACT TaskPersistenceContract -> -# { -# required_fields: {}, -# invariants: [ -# "persist_task creates or updates a record", -# "load_tasks retrieves valid Task instances", -# "delete_tasks correctly removes records from the database" -# ] -# } -# @TEST_FIXTURE valid_task_persistence -> {"task_id": "123", "status": "PENDING"} -# @TEST_EDGE persist_invalid_task_type -> raises Exception -# @TEST_EDGE load_corrupt_json_params -> handled gracefully -# @TEST_INVARIANT accurate_round_trip -> verifies: [valid_task_persistence, load_corrupt_json_params] class TaskPersistenceService: # #region Core.Persistence.JsonLoadIfNeeded [TYPE Function] [C:1] # @BRIEF: Safely load JSON strings from DB if necessary @@ -137,7 +124,7 @@ class TaskPersistenceService: return str(env.id) return None # #endregion Core.Persistence.ResolveEnvironmentId - # #region Core.Persistence.Init [C:2] [TYPE Function] [C:3] + # #region Core.Persistence.TaskPersistenceService.__init__ [C:2] [TYPE Function] # @BRIEF: Initializes the persistence service. # @PRE None. # @POST Service is ready. @@ -145,8 +132,8 @@ class TaskPersistenceService: with belief_scope("TaskPersistenceService.__init__"): # Use the unified SessionLocal from database.py. pass - # #endregion Core.Persistence.Init - # #region Core.Persistence.PersistTask [C:3] [TYPE Function] [C:3] + # #endregion Core.Persistence.TaskPersistenceService.__init__ + # #region Core.Persistence.PersistTask [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF: Persists or updates a single task in the database. # @PRE isinstance(task, Task) @@ -222,7 +209,7 @@ class TaskPersistenceService: finally: session.close() # #endregion Core.Persistence.PersistTask - # #region Core.Persistence.PersistTasks [C:3] [TYPE Function] [C:3] + # #region Core.Persistence.PersistTasks [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF: Persists multiple tasks. # @PRE isinstance(tasks, list) @@ -234,7 +221,7 @@ class TaskPersistenceService: for task in tasks: self.persist_task(task) # #endregion Core.Persistence.PersistTasks - # #region Core.Persistence.LoadTasks [C:3] [TYPE Function] [C:3] + # #region Core.Persistence.LoadTasks [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF: Loads tasks from the database. # @PRE limit is an integer. @@ -300,7 +287,7 @@ class TaskPersistenceService: finally: session.close() # #endregion Core.Persistence.LoadTasks - # #region Core.Persistence.DeleteTasks [C:3] [TYPE Function] [C:3] + # #region Core.Persistence.DeleteTasks [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF: Deletes specific tasks from the database. # @PRE task_ids is a list of strings. @@ -345,31 +332,19 @@ class TaskPersistenceService: # efficient SQL filtering/aggregation. File-per-task logging was rejected — loses # cross-task query capability and complicates lifecycle cleanup. # -# @TEST_CONTRACT TaskLogPersistenceContract -> -# { -# required_fields: {}, -# invariants: [ -# "add_logs efficiently saves logs to the database", -# "get_logs retrieves properly filtered LogEntry objects" -# ] -# } -# @TEST_FIXTURE valid_log_batch -> {"task_id": "123", "logs": [{"level": "INFO", "message": "msg"}]} -# @TEST_EDGE empty_log_list -> no-op behavior -# @TEST_EDGE add_logs_db_error -> rollback and log error -# @TEST_INVARIANT accurate_log_aggregation -> verifies: [valid_log_batch] class TaskLogPersistenceService: """ Service for persisting and querying task logs. Supports batch inserts, filtering, and statistics. """ - # #region Core.Persistence.Init [C:2] [TYPE Function] [C:3] + # #region Core.Persistence.TaskLogPersistenceService.__init__ [C:2] [TYPE Function] # @BRIEF: Initializes the TaskLogPersistenceService # @PRE config is provided or defaults are used # @POST Service is ready for log persistence def __init__(self, config=None): pass - # #endregion Core.Persistence.Init - # #region Core.Persistence.AddLogs [C:3] [TYPE Function] [C:3] + # #endregion Core.Persistence.TaskLogPersistenceService.__init__ + # #region Core.Persistence.AddLogs [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF: Batch insert log entries for a task. # @PRE logs is a list of LogEntry objects. @@ -425,7 +400,7 @@ class TaskLogPersistenceService: finally: session.close() # #endregion Core.Persistence.AddLogs - # #region Core.Persistence.GetLogs [C:3] [TYPE Function] [C:3] + # #region Core.Persistence.GetLogs [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF: Query logs for a task with filtering and pagination. # @PRE task_id is a valid task ID. @@ -480,7 +455,7 @@ class TaskLogPersistenceService: finally: session.close() # #endregion Core.Persistence.GetLogs - # #region Core.Persistence.GetLogStats [C:3] [TYPE Function] [C:3] + # #region Core.Persistence.GetLogStats [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF: Get statistics about logs for a task. # @PRE task_id is a valid task ID. @@ -525,7 +500,7 @@ class TaskLogPersistenceService: finally: session.close() # #endregion Core.Persistence.GetLogStats - # #region Core.Persistence.GetSources [C:3] [TYPE Function] [C:3] + # #region Core.Persistence.GetSources [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF: Get unique sources for a task's logs. # @PRE task_id is a valid task ID. @@ -604,7 +579,7 @@ class TaskLogPersistenceService: finally: session.close() # #endregion Core.Persistence.IterLogsForExport - # #region Core.Persistence.DeleteLogsForTask [C:3] [TYPE Function] [C:3] + # #region Core.Persistence.DeleteLogsForTask [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF: Delete all logs for a specific task. # @PRE task_id is a valid task ID. @@ -628,7 +603,7 @@ class TaskLogPersistenceService: finally: session.close() # #endregion Core.Persistence.DeleteLogsForTask - # #region Core.Persistence.DeleteLogsForTasks [C:3] [TYPE Function] [C:3] + # #region Core.Persistence.DeleteLogsForTasks [C:3] [TYPE Function] # @ingroup TaskManager # @BRIEF: Delete all logs for multiple tasks. # @PRE task_ids is a list of task IDs. diff --git a/backend/src/core/task_manager/task_logger.py b/backend/src/core/task_manager/task_logger.py index 4494b83a0..480a18eee 100644 --- a/backend/src/core/task_manager/task_logger.py +++ b/backend/src/core/task_manager/task_logger.py @@ -23,19 +23,6 @@ from ..logger import logger as main_cot_logger # noqa: TID252 # @INVARIANT All log calls include the task_id and source. # @UX_STATE Idle -> Logging -> (system records log) # -# @TEST_CONTRACT TaskLoggerContract -> -# { -# required_fields: {task_id: str, add_log_fn: Callable}, -# optional_fields: {source: str}, -# invariants: [ -# "All specific log methods (info, error) delegate to _log", -# "with_source creates a new logger with the same task_id" -# ] -# } -# @TEST_FIXTURE valid_task_logger -> {"task_id": "test_123", "add_log_fn": lambda *args: None, "source": "test_plugin"} -# @TEST_EDGE missing_task_id -> raises TypeError -# @TEST_EDGE invalid_add_log_fn -> raises TypeError -# @TEST_INVARIANT consistent_delegation -> verifies: [valid_task_logger] class TaskLogger: """ A dedicated logger for tasks that automatically tags logs with source attribution. diff --git a/backend/src/core/utils/network.py b/backend/src/core/utils/network.py index f5a1e4437..67de85601 100644 --- a/backend/src/core/utils/network.py +++ b/backend/src/core/utils/network.py @@ -23,7 +23,7 @@ from ..logger import belief_scope, logger as app_logger # #region Core.Network.SupersetAPIError [C:1] [TYPE Class] # @BRIEF Base exception for all Superset API related errors. class SupersetAPIError(Exception): - # #region Core.Network.Init [TYPE Function] [C:1] + # #region Core.Network.SupersetAPIError.__init__ [TYPE Function] [C:1] # @BRIEF: Initializes the exception with a message and context. # @PRE message is a string, context is a dict. # @POST Exception is initialized with context. @@ -31,48 +31,48 @@ class SupersetAPIError(Exception): with belief_scope("SupersetAPIError.__init__"): self.context = context super().__init__(f"[API_FAILURE] {message} | Context: {self.context}") - # #endregion Core.Network.Init + # #endregion Core.Network.SupersetAPIError.__init__ # #endregion Core.Network.SupersetAPIError # #region Core.Network.AuthenticationError [C:1] [TYPE Class] # @BRIEF Exception raised when authentication fails. class AuthenticationError(SupersetAPIError): - # #region Core.Network.Init [TYPE Function] [C:1] + # #region Core.Network.AuthenticationError.__init__ [TYPE Function] [C:1] # @BRIEF: Initializes the authentication error. # @PRE message is a string, context is a dict. # @POST AuthenticationError is initialized. def __init__(self, message: str = "Authentication failed", **context: Any): with belief_scope("AuthenticationError.__init__"): super().__init__(message, type="authentication", **context) - # #endregion Core.Network.Init + # #endregion Core.Network.AuthenticationError.__init__ # #endregion Core.Network.AuthenticationError # #region Core.Network.PermissionDeniedError [TYPE Class] -# @defgroup Core Module group. +# @ingroup Core # @BRIEF Exception raised when access is denied. class PermissionDeniedError(AuthenticationError): - # #region Core.Network.Init [TYPE Function] + # #region Core.Network.PermissionDeniedError.__init__ [TYPE Function] # @BRIEF: Initializes the permission denied error. # @PRE message is a string, context is a dict. # @POST PermissionDeniedError is initialized. def __init__(self, message: str = "Permission denied", **context: Any): with belief_scope("PermissionDeniedError.__init__"): super().__init__(message, **context) - # #endregion Core.Network.Init + # #endregion Core.Network.PermissionDeniedError.__init__ # #endregion Core.Network.PermissionDeniedError # #region Core.Network.DashboardNotFoundError [TYPE Class] -# @defgroup Core Module group. +# @ingroup Core # @BRIEF Exception raised when a dashboard cannot be found. class DashboardNotFoundError(SupersetAPIError): - # #region Core.Network.Init [TYPE Function] + # #region Core.Network.DashboardNotFoundError.__init__ [TYPE Function] # @BRIEF: Initializes the not found error with resource ID. # @PRE resource_id is provided. # @POST DashboardNotFoundError is initialized. def __init__(self, resource_id: int | str, message: str = "Dashboard not found", **context: Any): with belief_scope("DashboardNotFoundError.__init__"): super().__init__(f"Dashboard '{resource_id}' {message}", subtype="not_found", resource_id=resource_id, **context) - # #endregion Core.Network.Init + # #endregion Core.Network.DashboardNotFoundError.__init__ # #endregion Core.Network.DashboardNotFoundError # #region Core.Network.NetworkError [TYPE Class] -# @defgroup Core Module group. +# @ingroup Core # @BRIEF Exception raised when a network level error occurs. class NetworkError(Exception): # #region NetworkError.__init__ [TYPE Function] @@ -87,7 +87,7 @@ class NetworkError(Exception): # #endregion NetworkError.__init__ # #endregion Core.Network.NetworkError # #region Core.Network.SupersetAuthCache [TYPE Class] -# @defgroup Core Module group. +# @ingroup Core # @BRIEF Process-local cache for Superset access/csrf tokens keyed by environment credentials. # @PRE base_url and username are stable strings. # @POST Cached entries expire automatically by TTL and can be reused across requests. diff --git a/backend/src/core/utils/superset_context_extractor/_base.py b/backend/src/core/utils/superset_context_extractor/_base.py index bf8b92fef..2e092d0a7 100644 --- a/backend/src/core/utils/superset_context_extractor/_base.py +++ b/backend/src/core/utils/superset_context_extractor/_base.py @@ -39,7 +39,7 @@ class SupersetParsedContext: partial_recovery: bool = False dataset_payload: dict[str, Any] | None = None # #endregion Core.Base.SupersetParsedContext -# #region Core.Base.SupersetContextExtractorBase [C:4] [TYPE Class] +# #region Core.Base.SupersetContextExtractorBase.Class [C:4] [TYPE Class] # @defgroup Core Module group. # @BRIEF Bind extractor to one Superset environment and client instance; provide shared URL-parsing helpers. # @RELATION DEPENDS_ON -> [Core.ConfigModels.Environment] @@ -191,4 +191,4 @@ class SupersetContextExtractorBase: query_state[key] = decoded_value return query_state # #endregion SupersetContextExtractorBase._decode_query_state -# #endregion Core.Base.SupersetContextExtractorBase +# #endregion Core.Base.SupersetContextExtractorBase.Class diff --git a/backend/src/core/utils/superset_context_extractor/_filters.py b/backend/src/core/utils/superset_context_extractor/_filters.py index 207ab1cc5..f4a350ee6 100644 --- a/backend/src/core/utils/superset_context_extractor/_filters.py +++ b/backend/src/core/utils/superset_context_extractor/_filters.py @@ -14,7 +14,7 @@ from ...logger import logger as app_logger app_logger = cast(Any, app_logger) # #endregion Core.Filters.FiltersImports -# #region Core.Filters.SupersetContextFiltersExtractMixin [C:3] [TYPE Class] +# #region Core.Filters.SupersetContextFiltersExtractMixin.Class [C:3] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing query-state filter extraction for the composed SupersetContextExtractor. class SupersetContextFiltersExtractMixin: @@ -236,4 +236,4 @@ class SupersetContextFiltersExtractMixin: ) return imported_filters # #endregion SupersetContextFiltersExtractMixin._extract_imported_filters -# #endregion Core.Filters.SupersetContextFiltersExtractMixin +# #endregion Core.Filters.SupersetContextFiltersExtractMixin.Class diff --git a/backend/src/core/utils/superset_context_extractor/_parsing.py b/backend/src/core/utils/superset_context_extractor/_parsing.py index 9dd168c58..a8de4ff2d 100644 --- a/backend/src/core/utils/superset_context_extractor/_parsing.py +++ b/backend/src/core/utils/superset_context_extractor/_parsing.py @@ -16,7 +16,7 @@ from ._base import SupersetParsedContext logger = cast(Any, logger) # #endregion Core.Parsing.ParsingImports -# #region Core.Parsing.SupersetContextParsingMixin [C:4] [TYPE Class] +# #region Core.Parsing.SupersetContextParsingMixin.Class [C:4] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing Superset URL parsing logic for the composed SupersetContextExtractor. class SupersetContextParsingMixin: @@ -356,4 +356,4 @@ class SupersetContextParsingMixin: unresolved_references.append("dashboard_dataset_binding_missing") return None, unresolved_references # #endregion SupersetContextParsingMixin._recover_dataset_binding_from_dashboard -# #endregion Core.Parsing.SupersetContextParsingMixin +# #endregion Core.Parsing.SupersetContextParsingMixin.Class diff --git a/backend/src/core/utils/superset_context_extractor/_recovery.py b/backend/src/core/utils/superset_context_extractor/_recovery.py index cd0c221bf..e92a69f52 100644 --- a/backend/src/core/utils/superset_context_extractor/_recovery.py +++ b/backend/src/core/utils/superset_context_extractor/_recovery.py @@ -17,7 +17,7 @@ from ._base import SupersetParsedContext logger = cast(Any, logger) # #endregion Core.Recovery.RecoveryImports -# #region Core.Recovery.SupersetContextRecoveryMixin [C:4] [TYPE Class] +# #region Core.Recovery.SupersetContextRecoveryMixin.Class [C:4] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing filter recovery for the composed SupersetContextExtractor. class SupersetContextRecoveryMixin: @@ -286,4 +286,4 @@ class SupersetContextRecoveryMixin: "notes": str(payload.get("notes") or default_note), } # #endregion SupersetContextRecoveryMixin._normalize_imported_filter_payload -# #endregion Core.Recovery.SupersetContextRecoveryMixin +# #endregion Core.Recovery.SupersetContextRecoveryMixin.Class diff --git a/backend/src/core/utils/superset_context_extractor/_templates.py b/backend/src/core/utils/superset_context_extractor/_templates.py index b05ffe2fc..f33043d1e 100644 --- a/backend/src/core/utils/superset_context_extractor/_templates.py +++ b/backend/src/core/utils/superset_context_extractor/_templates.py @@ -14,7 +14,7 @@ from ...logger import belief_scope, logger logger = cast(Any, logger) # #endregion Core.Templates.TemplatesImports -# #region Core.Templates.SupersetContextTemplatesMixin [C:3] [TYPE Class] +# #region Core.Templates.SupersetContextTemplatesMixin.Class [C:3] [TYPE Class] # @defgroup Core Module group. # @BRIEF Mixin providing template variable discovery for the composed SupersetContextExtractor. class SupersetContextTemplatesMixin: @@ -227,4 +227,4 @@ class SupersetContextTemplatesMixin: except ValueError: return normalized_literal # #endregion SupersetContextTemplatesMixin._normalize_default_literal -# #endregion Core.Templates.SupersetContextTemplatesMixin +# #endregion Core.Templates.SupersetContextTemplatesMixin.Class diff --git a/backend/src/models/mapping.py b/backend/src/models/mapping.py index 185d2baee..b81d458ae 100644 --- a/backend/src/models/mapping.py +++ b/backend/src/models/mapping.py @@ -101,7 +101,6 @@ class MigrationJob(Base): # #region Models.Mapping.ResourceMapping [C:3] [TYPE Class] # @defgroup Models Module group. # @BRIEF Maps a universal UUID for a resource to its actual ID on a specific environment. -# @TEST_DATA: resource_mapping_record -> {'environment_id': 'prod-env-1', 'resource_type': 'chart', 'uuid': '123e4567-e89b-12d3-a456-426614174000', 'remote_integer_id': '42'} # @RELATION DEPENDS_ON -> Models.Mapping.MappingModels class ResourceMapping(Base): __tablename__ = "resource_mappings" diff --git a/backend/src/models/report.py b/backend/src/models/report.py index c6fe742b2..2e27c9f40 100644 --- a/backend/src/models/report.py +++ b/backend/src/models/report.py @@ -53,18 +53,6 @@ class ReportStatus(str, Enum): # @INVARIANT The properties accurately describe error state. # @BRIEF Error and recovery context for failed/partial reports. # -# @TEST_CONTRACT ErrorContextModel -> -# { -# required_fields: { -# message: str -# }, -# optional_fields: { -# code: str, -# next_actions: list[str] -# } -# } -# @TEST_FIXTURE basic_error -> {"message": "Connection timeout", "code": "ERR_504", "next_actions": ["retry"]} -# @TEST_EDGE missing_message -> {"code": "ERR_504"} # @RELATION DEPENDS_ON -> Models.Report.ReportModels class ErrorContext(BaseModel): code: str | None = None @@ -80,35 +68,6 @@ class ErrorContext(BaseModel): # @INVARIANT Must represent canonical task record attributes. # @BRIEF Canonical normalized report envelope for one task execution. # -# @TEST_CONTRACT TaskReportModel -> -# { -# required_fields: { -# report_id: str, -# task_id: str, -# task_type: TaskType, -# status: ReportStatus, -# updated_at: datetime, -# summary: str -# }, -# invariants: [ -# "report_id is a non-empty string", -# "task_id is a non-empty string", -# "summary is a non-empty string" -# ] -# } -# @TEST_FIXTURE valid_task_report -> -# { -# report_id: "rep-123", -# task_id: "task-456", -# task_type: "migration", -# status: "success", -# updated_at: "2026-02-26T12:00:00Z", -# summary: "Migration completed successfully" -# } -# @TEST_EDGE empty_report_id -> {"report_id": " ", "task_id": "task-456", "task_type": "migration", "status": "success", "updated_at": "2026-02-26T12:00:00Z", "summary": "Done"} -# @TEST_EDGE empty_summary -> {"report_id": "rep-123", "task_id": "task-456", "task_type": "migration", "status": "success", "updated_at": "2026-02-26T12:00:00Z", "summary": ""} -# @TEST_EDGE invalid_task_type -> {"report_id": "rep-123", "task_id": "task-456", "task_type": "invalid_type", "status": "success", "updated_at": "2026-02-26T12:00:00Z", "summary": "Done"} -# @TEST_INVARIANT non_empty_validators -> verifies: [empty_report_id, empty_summary] # @RELATION DEPENDS_ON -> Models.Report.ReportModels class TaskReport(BaseModel): report_id: str @@ -139,24 +98,6 @@ class TaskReport(BaseModel): # @INVARIANT Time and pagination queries are mutually consistent. # @BRIEF Query object for server-side report filtering, sorting, and pagination. # -# @TEST_CONTRACT ReportQueryModel -> -# { -# optional_fields: { -# page: int, page_size: int, task_types: list[TaskType], statuses: list[ReportStatus], -# time_from: datetime, time_to: datetime, search: str, sort_by: str, sort_order: str -# }, -# invariants: [ -# "page >= 1", "1 <= page_size <= 100", -# "sort_by in {'updated_at', 'status', 'task_type'}", -# "sort_order in {'asc', 'desc'}", -# "time_from <= time_to if both exist" -# ] -# } -# @TEST_FIXTURE valid_query -> {"page": 1, "page_size":20, "sort_by": "updated_at", "sort_order": "desc"} -# @TEST_EDGE invalid_page_size_large -> {"page_size": 150} -# @TEST_EDGE invalid_sort_by -> {"sort_by": "unknown_field"} -# @TEST_EDGE invalid_time_range -> {"time_from": "2026-02-26T12:00:00Z", "time_to": "2026-02-25T12:00:00Z"} -# @TEST_INVARIANT attribute_constraints_enforced -> verifies: [invalid_page_size_large, invalid_sort_by, invalid_time_range] # @RELATION DEPENDS_ON -> Models.Report.ReportModels class ReportQuery(BaseModel): page: int = Field(default=1, ge=1) @@ -199,15 +140,6 @@ class ReportQuery(BaseModel): # @INVARIANT Represents paginated data correctly. # @BRIEF Paginated collection of normalized task reports. # -# @TEST_CONTRACT ReportCollectionModel -> -# { -# required_fields: { -# items: list[TaskReport], total: int, page: int, page_size: int, has_next: bool, applied_filters: ReportQuery -# }, -# invariants: ["total >= 0", "page >= 1", "page_size >= 1"] -# } -# @TEST_FIXTURE empty_collection -> {"items": [], "total": 0, "page": 1, "page_size": 20, "has_next": False, "applied_filters": {}} -# @TEST_EDGE negative_total -> {"items": [], "total": -5, "page": 1, "page_size": 20, "has_next": False, "applied_filters": {}} # @RELATION DEPENDS_ON -> Models.Report.ReportModels class ReportCollection(BaseModel): items: list[TaskReport] @@ -226,13 +158,6 @@ class ReportCollection(BaseModel): # @INVARIANT Incorporates a report and logs correctly. # @BRIEF Detailed report representation including diagnostics and recovery actions. # -# @TEST_CONTRACT ReportDetailViewModel -> -# { -# required_fields: {report: TaskReport}, -# optional_fields: {timeline: list[dict], diagnostics: dict, next_actions: list[str]} -# } -# @TEST_FIXTURE valid_detail -> {"report": {"report_id": "rep-1", "task_id": "task-1", "task_type": "backup", "status": "success", "updated_at": "2026-02-26T12:00:00Z", "summary": "Done"}} -# @TEST_EDGE missing_report -> {} # @RELATION DEPENDS_ON -> Models.Report.ReportModels class ReportDetailView(BaseModel): report: TaskReport @@ -249,7 +174,6 @@ class ReportDetailView(BaseModel): # @BRIEF Aggregated task counts by type and status for the summary dashboard. # @DATA_CONTRACT Output: TaskSummary → frontend interface TaskSummary # @RELATION DEPENDS_ON -> [Models.Report.TaskReport] -# @TEST_CONTRACT TaskSummaryModel -> { invariants: ["all counts >= 0", "total == sum of all status counts across all types"] } class StatusCounts(BaseModel): """Counts per status for a single task type.""" pending: int = Field(default=0, ge=0) diff --git a/backend/src/models/task.py b/backend/src/models/task.py index 1ec8af356..9b4845642 100644 --- a/backend/src/models/task.py +++ b/backend/src/models/task.py @@ -39,60 +39,6 @@ class TaskRecord(Base): # @RELATION DEPENDS_ON -> Models.Task.TaskRecord # @INVARIANT Each log entry belongs to exactly one task. # -# @TEST_CONTRACT TaskLogCreate -> -# { -# required_fields: { -# task_id: str, -# timestamp: datetime, -# level: str, -# source: str, -# message: str -# }, -# optional_fields: { -# metadata_json: str, -# id: int -# }, -# invariants: [ -# "task_id matches an existing TaskRecord.id" -# ] -# } -# -# @TEST_FIXTURE basic_info_log -> -# { -# task_id: "00000000-0000-0000-0000-000000000000", -# timestamp: "2026-02-26T12:00:00Z", -# level: "INFO", -# source: "system", -# message: "Task initialization complete" -# } -# -# @TEST_EDGE missing_required_field -> -# { -# timestamp: "2026-02-26T12:00:00Z", -# level: "ERROR", -# source: "system", -# message: "Missing task_id" -# } -# -# @TEST_EDGE invalid_type -> -# { -# task_id: "00000000-0000-0000-0000-000000000000", -# timestamp: "2026-02-26T12:00:00Z", -# level: 500, -# source: "system", -# message: "Integer level" -# } -# -# @TEST_EDGE empty_message -> -# { -# task_id: "00000000-0000-0000-0000-000000000000", -# timestamp: "2026-02-26T12:00:00Z", -# level: "DEBUG", -# source: "system", -# message: "" -# } -# -# @TEST_INVARIANT exact_one_task_association -> verifies: [basic_info_log, missing_required_field] class TaskLogRecord(Base): __tablename__ = "task_logs" diff --git a/backend/src/plugins/llm_analysis/_constants.py b/backend/src/plugins/llm_analysis/_constants.py new file mode 100644 index 000000000..7d6b89df2 --- /dev/null +++ b/backend/src/plugins/llm_analysis/_constants.py @@ -0,0 +1,16 @@ +# #region Plugin.Service.Constants [C:1] [TYPE Module] [SEMANTICS llm,screenshot,timeouts] +# @defgroup LLMAnalysis Module group. +# @BRIEF Named timeout constants for LLM analysis and screenshot services. +# @LAYER Plugin +# @RATIONALE Extracted all hardcoded timeouts into named module-level constants. Zero remaining numeric timeout literals. +# @REJECTED Keeping inline numeric timeout literals was rejected — they create a maintenance hazard where any timeout adjustment requires grep-and-fix across 1700+ lines; named constants centralize tuning and make timeout configuration auditable. + +# 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" +# #endregion Plugin.Service.Constants diff --git a/backend/src/plugins/llm_analysis/_dataset_health.py b/backend/src/plugins/llm_analysis/_dataset_health.py new file mode 100644 index 000000000..72e2f73f6 --- /dev/null +++ b/backend/src/plugins/llm_analysis/_dataset_health.py @@ -0,0 +1,215 @@ +# #region Plugin.Service.DatasetHealthModule [C:3] [TYPE Module] [SEMANTICS llm,dataset,health,superset] +# @defgroup LLMAnalysis Module group. +# @BRIEF Dataset accessibility and chart-data health checks via Superset API. +# @LAYER Plugin + +import asyncio +import json +from typing import Any + +# #region Plugin.Service.DatasetHealthChecker [C:3] [TYPE Class] +# @defgroup LLMAnalysis Module group. +# @BRIEF Checks dataset accessibility and KXD connectivity via Superset API. +# @LAYER Service +# @RELATION CALLS -> [Core.Init.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__ [C:2] [TYPE Function] + # @BRIEF 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 [C:2] [TYPE Function] + # @BRIEF 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 [C:3] [TYPE Function] + # @BRIEF 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 [C:3] [TYPE Function] + # @BRIEF 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 [C:3] [TYPE Function] + # @BRIEF 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 Plugin.Service.DatasetHealthChecker +# #endregion Plugin.Service.DatasetHealthModule diff --git a/backend/src/plugins/llm_analysis/_llm_client_analysis.py b/backend/src/plugins/llm_analysis/_llm_client_analysis.py new file mode 100644 index 000000000..66c6b1cb6 --- /dev/null +++ b/backend/src/plugins/llm_analysis/_llm_client_analysis.py @@ -0,0 +1,351 @@ +# #region Plugin.Service.LLMClient.AnalysisModule [C:4] [TYPE Module] [SEMANTICS llm,analysis,multimodal,batch] +# @defgroup LLMAnalysis Module group. +# @BRIEF Dashboard analysis paths over the LLM transport: multimodal images and text batch (mixin). +# @LAYER Plugin + +import asyncio +import base64 +import io + +from typing import Any + +from PIL import Image +from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential + +from ...core.logger import belief_scope, logger +from ...services.llm_prompt_templates import DEFAULT_LLM_PROMPTS, render_prompt +from .exceptions import ProviderAuthenticationFailure, ProviderConfigurationFailure +from ._llm_client_core import _should_retry + + +# #region Plugin.Service.LLMClientAnalysisMixin [C:4] [TYPE Class] +# @ingroup LLMAnalysis +# @BRIEF Mixin: image optimization, chunk merging, multimodal and text-batch dashboard analysis. +class LLMClientAnalysisMixin: + # #region LLMClient.analyze_dashboard [C:4] [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. + # @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] + # @BRIEF 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] + # @BRIEF 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] + # @BRIEF 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] + # @BRIEF 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.explore("Image optimization failed, falling back to raw read", payload={"path": path}, error=str(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] + # @BRIEF 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] + # @BRIEF 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] + # @BRIEF Path A: send screenshots + logs to multimodal LLM, with chunking support. + # @PRE screenshot_paths is a non-empty list of paths. + # tab_labels, if provided, must have the same length as screenshot_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, + tab_labels: list[str] | 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) + tab_list_text = "\n".join(f" Screenshot {i}: {label}" for i, label in enumerate(tab_labels or [])) or "Screenshots are in order." + prompt = render_prompt( + prompt_template, + { + "logs": log_text, + "tab_list": tab_list_text, + "total_chunks": str(len(encoded_images)), + }, + ) + + # 2. Determine chunking + # Default to 8 images per chunk as a safe fallback when max_images is 0 or None + # (0 means probe failed — e.g. Kilo gateway doesn't support OpenAI image format) + DEFAULT_CHUNK_SIZE = 8 + effective_max = max_images if (max_images is not None and max_images > 0) else DEFAULT_CHUNK_SIZE + n_total = len(encoded_images) + chunk_size = effective_max if effective_max < n_total else n_total + is_chunking = chunk_size < n_total + + if is_chunking: + logger.reason( + "Chunking images for multimodal analysis", + payload={"total_images": n_total, "chunk_count": (n_total + chunk_size - 1) // chunk_size, "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.reason( + "Reducing image quality to fit context window", + payload={"pct_of_limit": estimate["pct_of_limit"], "new_quality": 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): + # Re-raise permanent provider errors (auth, config) — they apply to all chunks + normalized = self._normalize_provider_error(cr) + if not normalized.retryable: + logger.explore( + "Multimodal analysis chunk failed with permanent provider error, aborting", + payload={"chunk_index": i + 1, "error_type": type(normalized).__name__}, + error=str(cr), + ) + raise normalized from cr + # Transient chunk failures can be merged as UNKNOWN + logger.explore("Multimodal analysis chunk failed (transient)", payload={"chunk_index": i + 1, "total_chunks": len(chunks)}, error=str(cr)) + 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 (ProviderAuthenticationFailure, ProviderConfigurationFailure): + # Permanent provider errors propagate to TaskManager — no UNKNOWN swallow + raise + except Exception as e: + # Transient errors and unexpected failures → UNKNOWN (existing behavior) + normalized = self._normalize_provider_error(e) + if not normalized.retryable: + raise normalized from e + logger.explore("Failed to get multimodal analysis from LLM", payload={}, error=str(e)) + 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] + # @BRIEF 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{top}\n\nDataset health:\n{p.get("dataset_health", "")}\n\nLogs:\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 Plugin.Service.LLMClientAnalysisMixin +# #endregion Plugin.Service.LLMClient.AnalysisModule diff --git a/backend/src/plugins/llm_analysis/_llm_client_core.py b/backend/src/plugins/llm_analysis/_llm_client_core.py new file mode 100644 index 000000000..1af2b0d18 --- /dev/null +++ b/backend/src/plugins/llm_analysis/_llm_client_core.py @@ -0,0 +1,391 @@ +# #region Plugin.Service.LLMClient.CoreModule [C:4] [TYPE Module] [SEMANTICS llm,client,provider,openai,retry] +# @defgroup LLMAnalysis Module group. +# @BRIEF Core LLM provider transport: client init, SSL, error normalization, JSON completion (mixin). +# @LAYER Plugin + +import asyncio +import json +import os +import ssl + +import httpx +from openai import AsyncOpenAI, AuthenticationError as OpenAIAuthenticationError, RateLimitError +from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential + +from ...core.logger import belief_scope, logger +from .exceptions import ( + ProviderAuthenticationFailure, + ProviderFailure, + ProviderRateLimitFailure, + ProviderTransportFailure, +) +from .models import LLMProviderType +from typing import Any + +from ._constants import LLM_HTTP_TIMEOUT_S + + +# #region Plugin.Service.ShouldRetry [C:2] [TYPE Function] [SEMANTICS llm,retry,policy] +# @BRIEF Custom retry predicate for Tenacity — excludes non-recoverable errors from LLM retry loops. +# @PRE exception is an Exception raised during LLM API call. +# @POST Returns True if the error is retryable (transport, rate limit), False for permanent errors (auth, config). +# @RELATION CALLED_BY -> [Plugin.Service.LLMClient.get_json_completion] +# @RELATION CALLED_BY -> [Plugin.Service.LLMClient.analyze_dashboard_text_batch] +# @RATIONALE Extracted to module level because it is referenced by @retry decorators in two separate methods +# (get_json_completion and analyze_dashboard_text_batch). A nested function would be fragile — +# moving either method would break the other's retry configuration. +def _should_retry(exception: Exception) -> bool: + """Custom retry predicate that excludes non-recoverable errors.""" + # Typed provider failures use their native retryability + if isinstance(exception, ProviderFailure): + return exception.retryable + # Don't retry on OpenAIAuthenticationError + if isinstance(exception, OpenAIAuthenticationError): + return False + # Don't retry on null content / model errors — retrying won't help + msg = str(exception).lower() + if "null content" in msg or "none" in msg: + return False + # Retry on rate limit errors + if isinstance(exception, RateLimitError): + return True + # For other exceptions, limit retries + return True +# #endregion Plugin.Service.ShouldRetry + + +# #region Plugin.Service.LLMClientCoreMixin [C:4] [TYPE Class] +# @ingroup LLMAnalysis +# @BRIEF Mixin: provider client construction, SSL policy, typed error normalization, JSON-mode completion. +class LLMClientCoreMixin: + # #region LLMClient.__init__ [C:2] [TYPE Function] + # @BRIEF 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.reason( + "Initializing LLM client", + payload={ + "provider_type": str(provider_type), + "base_url": base_url, + "default_model": default_model, + "api_key_present": bool(self.api_key), + "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() + default_headers["X-Title"] = os.getenv("OPENROUTER_APP_NAME", "").strip() or "" + 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() + from ...core.ssl import describe_context + + ssl_desc = describe_context(ssl_verify) + logger.reason("LLM client SSL verification configured", payload={"ssl_verify": ssl_desc}) + + 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 [C:3] [TYPE Function] + # @BRIEF Resolve SSL verification flag from environment. + # @POST Returns SSLContext with system CA dir (never False — centralized SSL). + # @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= отвергнут — 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: + from ...core.ssl import system_ssl_context + + return system_ssl_context() + + # #endregion LLMClient._get_ssl_verify + + # #region LLMClient._format_connection_error [C:2] [TYPE Function] + # @BRIEF 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 [C:3] [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 _supports_json_response_format(self) -> bool: + model = (self.default_model or "").lower() + + # Free-tier models from ANY gateway often reject json_object mode + # (Nvidia NeMo free via OpenRouter or Kilo, stepfun free, etc.) + if ":free" in model: + return False + # stepfun models (even non-free) don't support json_object mode + if "stepfun/" in model or model.startswith("step-"): + return False + return True + + # #endregion LLMClient._supports_json_response_format + + # #region LLMClient._normalize_provider_error [C:3] [TYPE Function] + # @BRIEF Normalize OpenAI/HTTP exceptions into typed ProviderFailure hierarchy. + # @PRE exc is a raw exception from the OpenAI SDK or httpx client. + # @POST Returns a ProviderFailure subclass that expresses retryability. + # @SIDE_EFFECT Logs the normalization decision. + # @RATIONALE OpenAI SDK exceptions (OpenAIAuthenticationError, etc.) carry status codes and + # messages that map to typed provider errors. httpx transport errors map to + # ProviderTransportFailure. Generic exceptions with "401" patterns are caught + # via string fallback for non-OpenAI gateways. + @staticmethod + def _normalize_provider_error(exc: Exception, provider_id: str | None = None) -> ProviderFailure: + # Already a typed failure — pass through + if isinstance(exc, ProviderFailure): + return exc + + # OpenAI SDK auth error + if isinstance(exc, OpenAIAuthenticationError): + return ProviderAuthenticationFailure( + str(exc), provider_id=provider_id, status_code=401, original=exc, + ) + + # OpenAI rate limit + if isinstance(exc, RateLimitError): + return ProviderRateLimitFailure( + str(exc), provider_id=provider_id, status_code=429, original=exc, + ) + + # httpx transport errors (connection, timeout, DNS) + if isinstance(exc, httpx.TimeoutException): + return ProviderTransportFailure( + str(exc), provider_id=provider_id, original=exc, + ) + if isinstance(exc, httpx.ConnectError): + return ProviderTransportFailure( + str(exc), provider_id=provider_id, original=exc, + ) + + # HTTP status code via status_code attribute (OpenAI-like SDKs) + status_code = getattr(exc, "status_code", None) or getattr(exc, "status", None) + if status_code is not None: + status_code = int(status_code) # type: ignore[arg-type] + if status_code in (401, 403): + return ProviderAuthenticationFailure( + str(exc), provider_id=provider_id, status_code=status_code, original=exc, + ) + if status_code == 429: + return ProviderRateLimitFailure( + str(exc), provider_id=provider_id, status_code=429, original=exc, + ) + if status_code >= 500: + return ProviderTransportFailure( + str(exc), provider_id=provider_id, status_code=status_code, original=exc, + ) + + # String fallback for non-OpenAI gateways that embed an auth status in generic errors. + msg = str(exc).lower() + if "401" in msg or "403" in msg or "authentication" in msg or "unauthorized" in msg: + return ProviderAuthenticationFailure( + str(exc), + provider_id=provider_id, + status_code=401 if "401" in msg else 403 if "403" in msg else None, + original=exc, + ) + if "429" in msg or "rate limit" in msg: + return ProviderRateLimitFailure( + str(exc), provider_id=provider_id, original=exc, + ) + + # Default: assume transport-level failure (retryable) + return ProviderTransportFailure( + str(exc), provider_id=provider_id, original=exc, + ) + # #endregion LLMClient._normalize_provider_error + + # #region LLMClient.get_json_completion [C:4] [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. + @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.reason( + "Attempting LLM call", + payload={ + "model": self.default_model, + "json_mode": "on" if use_json_mode else "off", + "base_url": self.base_url, + "message_count": len(messages), + "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.explore("JSON mode failed or not supported, falling back to plain text", payload={"model": self.default_model}, error=str(e)) + response = await self.client.chat.completions.create(model=self.default_model, messages=messages) + else: + raise e + + logger.reflect("LLM API response received", payload={"response_summary": str(response)[:200]}) + except RateLimitError as e: + logger.explore("Rate limit hit on LLM call, retrying with backoff", payload={}, error=str(e)) + + # 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.explore("Failed to parse retry delay from error response", payload={}, error=str(parse_e)) + + # Add a small safety margin (0.5s) as requested + wait_time = retry_delay + 0.5 + logger.reason("Waiting before LLM retry", payload={"wait_time_seconds": wait_time}) + await asyncio.sleep(wait_time) + raise + except Exception as e: + # Normalize into typed provider exception chain + provider_exc = self._normalize_provider_error(e, provider_id=self.default_model) + logger.explore( + "LLM call failed, normalized as", + payload={"type": type(provider_exc).__name__, "status_code": provider_exc.status_code}, + error=str(provider_exc), + ) + raise provider_exc from e + + 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.reflect("Raw LLM response content received for parsing", payload={"content_length": len(content) if content else 0}) + + # LLM returned null content — likely content filter or rate limit + if content is None: + raise RuntimeError("LLM returned null content (content filter or rate limit)") + + try: + return json.loads(content) + except json.JSONDecodeError: + logger.explore("Failed to parse JSON directly, attempting to extract from code blocks", payload={}, error="JSONDecodeError on first parse attempt") + 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 [C:3] [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. + 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 [C:3] [TYPE Function] + # @BRIEF 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( + "Fetched available models from provider", + payload={"model_count": len(model_ids), "base_url": self.base_url}, + ) + return model_ids + except Exception as e: + logger.explore( + "Failed to fetch models from provider", + payload={"base_url": self.base_url, "formatted_error": self._format_connection_error(e)}, + error=str(e), + ) + raise + + # #endregion LLMClient.fetch_models +# #endregion Plugin.Service.LLMClientCoreMixin +# #endregion Plugin.Service.LLMClient.CoreModule diff --git a/backend/src/plugins/llm_analysis/_redaction.py b/backend/src/plugins/llm_analysis/_redaction.py new file mode 100644 index 000000000..7b9d464fb --- /dev/null +++ b/backend/src/plugins/llm_analysis/_redaction.py @@ -0,0 +1,60 @@ +# #region Plugin.Service.RedactionModule [C:2] [TYPE Module] [SEMANTICS llm,redaction,pii,security] +# @defgroup LLMAnalysis Module group. +# @BRIEF Redacts PII, credentials, and sensitive data from logs and LLM responses. +# @LAYER Plugin + +import re + +# #region Plugin.Service.RedactionService [C:2] [TYPE Module] +# @defgroup LLMAnalysis Module group. +# @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 Plugin.Service.RedactionService +# #endregion Plugin.Service.RedactionModule diff --git a/backend/src/plugins/llm_analysis/_screenshot.py b/backend/src/plugins/llm_analysis/_screenshot.py new file mode 100644 index 000000000..cdef19a75 --- /dev/null +++ b/backend/src/plugins/llm_analysis/_screenshot.py @@ -0,0 +1,39 @@ +# #region Plugin.Service.Screenshot [C:4] [TYPE Module] [SEMANTICS llm,screenshot,facade] +# @defgroup LLMAnalysis Module group. +# @BRIEF Facade assembling ScreenshotService from login/wait/session/capture/media mixins. +# @LAYER Plugin +# @RELATION DEPENDS_ON -> [Plugin.Service.Screenshot.LoginModule] +# @RELATION DEPENDS_ON -> [Plugin.Service.Screenshot.WaitModule] +# @RELATION DEPENDS_ON -> [Plugin.Service.Screenshot.SessionModule] +# @RELATION DEPENDS_ON -> [Plugin.Service.Screenshot.CaptureModule] +# @RELATION DEPENDS_ON -> [Plugin.Service.Screenshot.MediaModule] + +from ...core.config_models import Environment +from ._screenshot_capture import ScreenshotCaptureMixin +from ._screenshot_login import ScreenshotLoginMixin +from ._screenshot_media import ScreenshotMediaMixin +from ._screenshot_session import ScreenshotSessionMixin +from ._screenshot_wait import ScreenshotWaitMixin + +__all__ = ["ScreenshotService"] + + +# #region Plugin.Service.ScreenshotService [C:4] [TYPE Class] [SEMANTICS llm,screenshot,playwright] +# @defgroup LLMAnalysis Module group. +# @BRIEF Handles capturing screenshots of Superset dashboards. +# @SIDE_EFFECT Launches Playwright browser; captures screenshots to disk. +class ScreenshotService( + ScreenshotLoginMixin, + ScreenshotWaitMixin, + ScreenshotSessionMixin, + ScreenshotCaptureMixin, + ScreenshotMediaMixin, +): + # #region ScreenshotService.__init__ [C:1] [TYPE Function] [SEMANTICS init] + # @BRIEF Initializes the ScreenshotService with environment configuration. + # @PRE env is a valid Environment object. + def __init__(self, env: Environment): + self.env = env + # #endregion ScreenshotService.__init__ +# #endregion Plugin.Service.ScreenshotService +# #endregion Plugin.Service.Screenshot diff --git a/backend/src/plugins/llm_analysis/_screenshot_capture.py b/backend/src/plugins/llm_analysis/_screenshot_capture.py new file mode 100644 index 000000000..730b51067 --- /dev/null +++ b/backend/src/plugins/llm_analysis/_screenshot_capture.py @@ -0,0 +1,256 @@ +# #region Plugin.Service.Screenshot.CaptureModule [C:4] [TYPE Module] [SEMANTICS llm,screenshot,capture,tabs] +# @defgroup LLMAnalysis Module group. +# @BRIEF Per-tab CDP screenshot capture and orchestration for ScreenshotService (mixin). +# @LAYER Plugin + +import base64 +import os +import re + +from playwright.async_api import async_playwright + +from ...core.logger import belief_scope, logger +from ._constants import PLAYWRIGHT_WAIT_TIMEOUT_MS + + +# #region Plugin.Service.ScreenshotCaptureMixin [C:4] [TYPE Class] +# @ingroup LLMAnalysis +# @BRIEF Mixin: browser session launch delegation, per-tab chunked capture, PNG->JPEG/WebP pipeline. +class ScreenshotCaptureMixin: + # #region ScreenshotService.capture_dashboard_chunks [C:4] [TYPE Function] + # @BRIEF 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.reason("Found tabs at current depth", payload={"tab_count": len(found_tabs), "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.reason("Switching to tab", payload={"tab_text": tab_text, "depth": depth, "index": i}) + + 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.explore( + "Content verification timed out after tab switch", + payload={"tab_text": tab_text}, + error="Verification wait timed out", + ) + + # 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}_d{depth}.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.explore( + "CDP screenshot failed, falling back to Playwright full_page", + payload={"tab_text": tab_text}, + error=str(cdp_err), + ) + await page.screenshot(path=tab_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS) + + logger.reason("Saved screenshot for tab", payload={"tab_path": tab_path, "tab_text": tab_text}) + results.append({"tab_name": tab_text, "path": tab_path}) + + # Recurse into nested tabs + await _capture_tabs(depth + 1) + + except Exception as tab_e: + logger.explore( + "Failed to process tab", + payload={"tab_index": i, "depth": depth}, + error=str(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.reason("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.explore("CDP full-page fallback failed, using Playwright full_page", payload={}, error=str(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 [C:4] [TYPE Function] + # @BRIEF 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.reason("Capturing dashboard screenshots", payload={"dashboard_id": 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.explore("No screenshots captured for dashboard", payload={"dashboard_id": dashboard_id}, error="All capture attempts returned empty") + return [], [] + + # 2. Convert PNGs to JPEGs for LLM + jpeg_paths = self._convert_screenshots_for_llm(png_paths, output_dir) + logger.reason( + "Converted PNGs to JPEGs for LLM analysis", + payload={"converted": len(jpeg_paths), "total": len(png_paths)}, + ) + + # 3. Archive to WebP (deletes PNGs on success) + archive_results = self._archive_screenshots_as_webp(png_paths, output_dir) + archived_count = sum(1 for r in archive_results if r.get("webp_path")) + logger.reason( + "Archived screenshots to WebP", + payload={"archived": archived_count, "total": len(archive_results)}, + ) + + # 4. Return JPEGs for LLM — caller cleans up after analysis + return jpeg_paths, archive_results + + # #endregion ScreenshotService.capture_dashboard +# #endregion Plugin.Service.ScreenshotCaptureMixin +# #endregion Plugin.Service.Screenshot.CaptureModule diff --git a/backend/src/plugins/llm_analysis/_screenshot_login.py b/backend/src/plugins/llm_analysis/_screenshot_login.py new file mode 100644 index 000000000..bd0a275ec --- /dev/null +++ b/backend/src/plugins/llm_analysis/_screenshot_login.py @@ -0,0 +1,264 @@ +# #region Plugin.Service.Screenshot.LoginModule [C:3] [TYPE Module] [SEMANTICS llm,screenshot,login,playwright] +# @defgroup LLMAnalysis Module group. +# @BRIEF Login and navigation locator helpers for ScreenshotService (mixin). +# @LAYER Plugin + +from typing import Any +from urllib.parse import urlsplit + +from ...core.logger import logger +from ._constants import HTTP_REQUEST_TIMEOUT_MS, PLAYWRIGHT_WAIT_TIMEOUT_MS + + +# #region Plugin.Service.ScreenshotLoginMixin [C:3] [TYPE Class] +# @ingroup LLMAnalysis +# @BRIEF Mixin: login-form discovery, fallback form POST, resilient navigation. +class ScreenshotLoginMixin: + # #region ScreenshotService._find_first_visible_locator [C:3] [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. + 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 [C:2] [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 _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 [C:2] [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. + 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 [C:2] [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. + 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 [C:2] [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 _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 [C:2] [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 _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 [C:3] [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. + 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.explore("Direct form login fallback skipped: csrf_token not found", error="Missing csrf_token in login form") + return False + + try: + request_context = page.context.request + except Exception as context_error: + logger.explore("Direct form login fallback skipped: request context unavailable", payload={"error": str(context_error)}, error="Browser request context unavailable") + 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.reason( + "Attempting direct form login fallback via browser context request", + payload={"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.reason( + "Direct form login fallback redirect response", + payload={"status": response_status, "url": response_url, "location": redirect_location, "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.reason( + "Direct form login fallback response", + payload={"status": response_status, "url": response_url, "login_markup": looks_like_login_page, "snippet": text_snippet}, + ) + return not looks_like_login_page + + # #endregion ScreenshotService._submit_login_via_form_post + + # #region ScreenshotService._find_login_field_locator [C:3] [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. + 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 [C:3] [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. + 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 [C:3] [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. + 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.explore( + "Primary navigation wait failed, falling back to fallback wait", + payload={"primary_wait_until": primary_wait_until, "url": url}, + error=str(primary_error), + ) + return await page.goto(url, wait_until=fallback_wait_until, timeout=timeout) + + # #endregion ScreenshotService._goto_resilient +# #endregion Plugin.Service.ScreenshotLoginMixin +# #endregion Plugin.Service.Screenshot.LoginModule diff --git a/backend/src/plugins/llm_analysis/_screenshot_media.py b/backend/src/plugins/llm_analysis/_screenshot_media.py new file mode 100644 index 000000000..81cff3a7e --- /dev/null +++ b/backend/src/plugins/llm_analysis/_screenshot_media.py @@ -0,0 +1,102 @@ +# #region Plugin.Service.Screenshot.MediaModule [C:2] [TYPE Module] [SEMANTICS llm,screenshot,jpeg,webp,archive] +# @defgroup LLMAnalysis Module group. +# @BRIEF Screenshot format conversion and cleanup helpers for ScreenshotService (mixin). +# @LAYER Plugin + +import os + +from PIL import Image + +from ...core.logger import logger + + +# #region Plugin.Service.ScreenshotMediaMixin [C:2] [TYPE Class] +# @ingroup LLMAnalysis +# @BRIEF Mixin: PNG to JPEG conversion for LLM, WebP archiving, temp-file cleanup. +class ScreenshotMediaMixin: + # #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.explore("Failed to convert screenshot for LLM", payload={"png_path": png_path}, error=str(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.explore("Failed to archive screenshot to WebP, keeping PNG", payload={"png_path": png_path}, error=str(e)) + 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.explore("Failed to delete temporary file", payload={"path": path}, error=str(e)) + + # #endregion ScreenshotService._cleanup_temp_files +# #endregion Plugin.Service.ScreenshotMediaMixin +# #endregion Plugin.Service.Screenshot.MediaModule diff --git a/backend/src/plugins/llm_analysis/_screenshot_session.py b/backend/src/plugins/llm_analysis/_screenshot_session.py new file mode 100644 index 000000000..6463c2e62 --- /dev/null +++ b/backend/src/plugins/llm_analysis/_screenshot_session.py @@ -0,0 +1,187 @@ +# #region Plugin.Service.Screenshot.SessionModule [C:4] [TYPE Module] [SEMANTICS llm,screenshot,session,login,navigation] +# @defgroup LLMAnalysis Module group. +# @BRIEF Browser session launch, UI login and dashboard navigation for ScreenshotService (mixin). +# @LAYER Plugin + +from typing import Any + +from ...core.logger import logger +from ._constants import HTTP_REQUEST_TIMEOUT_MS, PLAYWRIGHT_NAVIGATION_TIMEOUT_MS + + +# #region Plugin.Service.ScreenshotSessionMixin [C:4] [TYPE Class] +# @ingroup LLMAnalysis +# @BRIEF Mixin: headless Chromium launch with anti-automation flags, login flow, dashboard navigation. +class ScreenshotSessionMixin: + # #region ScreenshotService._launch_and_login [C:4] [TYPE Function] + # @BRIEF 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.reflect("Login and navigation to dashboard successful", payload={"dashboard_id": dashboard_id}) + return browser, context, page + + # #endregion ScreenshotService._launch_and_login +# #endregion Plugin.Service.ScreenshotSessionMixin +# #endregion Plugin.Service.Screenshot.SessionModule diff --git a/backend/src/plugins/llm_analysis/_screenshot_wait.py b/backend/src/plugins/llm_analysis/_screenshot_wait.py new file mode 100644 index 000000000..078423c76 --- /dev/null +++ b/backend/src/plugins/llm_analysis/_screenshot_wait.py @@ -0,0 +1,88 @@ +# #region Plugin.Service.Screenshot.WaitModule [C:2] [TYPE Module] [SEMANTICS llm,screenshot,wait,polling] +# @defgroup LLMAnalysis Module group. +# @BRIEF Chart stabilization and debug-screenshot helpers for ScreenshotService (mixin). +# @LAYER Plugin + +import asyncio +import os + +from ...core.logger import logger + + +# #region Plugin.Service.ScreenshotWaitMixin [C:2] [TYPE Class] +# @ingroup LLMAnalysis +# @BRIEF Mixin: chart render polling, post-resize re-render wait, debug screenshots. +class ScreenshotWaitMixin: + # #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.explore("Chart stabilization wait timed out, proceeding anyway", error="Timed out waiting for charts to render") + + # #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.explore("Re-render wait timed out after viewport resize, proceeding anyway", error="Timed out waiting for charts to re-render after resize") + + # #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 +# #endregion Plugin.Service.ScreenshotWaitMixin +# #endregion Plugin.Service.Screenshot.WaitModule diff --git a/backend/src/plugins/llm_analysis/scripts/superset_auth_diag.py b/backend/src/plugins/llm_analysis/scripts/superset_auth_diag.py index 2d0ee7d19..9165d97d9 100644 --- a/backend/src/plugins/llm_analysis/scripts/superset_auth_diag.py +++ b/backend/src/plugins/llm_analysis/scripts/superset_auth_diag.py @@ -1,4 +1,7 @@ #!/usr/bin/env python3 +# #region Plugin.LlmAnalysis.AuthDiag [C:3] [TYPE Module] [SEMANTICS superset,auth,diagnostics] +# @defgroup LlmAnalysis Module group. +# @BRIEF Superset auth diagnostics — full-cycle verification of JWT/form/session auth paths. """ Superset Auth Diagnostics Script — Extended v3 @@ -31,6 +34,9 @@ except ImportError: # ── helpers ────────────────────────────────────────────────── +# #region Plugin.LlmAnalysis.AuthDiag.PrintHelpers [C:1] [TYPE Block] +# @ingroup LlmAnalysis +# @BRIEF Console output primitives for the diagnostic report. def banner(msg): print(f"\n{'='*60}") print(f" {msg}") @@ -47,13 +53,21 @@ def warn(msg): def section(msg): print(f"\n --- {msg} ---") +# #endregion Plugin.LlmAnalysis.AuthDiag.PrintHelpers +# #region Plugin.LlmAnalysis.AuthDiag.HttpClient [C:2] [TYPE Function] +# @ingroup LlmAnalysis +# @BRIEF Build a shared httpx client with optional SSL verification bypass. def http_client(verify: bool = True) -> httpx.Client: import warnings if not verify: warnings.filterwarnings("ignore", message=".*verify=False.*", category=UserWarning) return httpx.Client(verify=verify, timeout=30, follow_redirects=False) +# #endregion Plugin.LlmAnalysis.AuthDiag.HttpClient +# #region Plugin.LlmAnalysis.AuthDiag.ExtractSessionCookie [C:2] [TYPE Function] +# @ingroup LlmAnalysis +# @BRIEF Extract session cookie dict from a Set-Cookie header. def extract_session_cookie(set_cookie_header: str) -> dict | None: """Извлекает session=... из Set-Cookie заголовка.""" if not set_cookie_header: @@ -64,10 +78,14 @@ def extract_session_cookie(set_cookie_header: str) -> dict | None: value = part.split("=", 1)[1] return {"name": "session", "value": value} return None +# #endregion Plugin.LlmAnalysis.AuthDiag.ExtractSessionCookie # ── 1. Reachability ────────────────────────────────────────── +# #region Plugin.LlmAnalysis.AuthDiag.CheckReachability [C:3] [TYPE Function] +# @ingroup LlmAnalysis +# @BRIEF Probe Superset reachability via /health/ with base-URL fallback. def check_reachability(base_url: str, verify: bool) -> bool: banner("1. Доступность Superset") with http_client(verify) as c: @@ -84,10 +102,14 @@ def check_reachability(base_url: str, verify: bool) -> bool: except httpx.RequestError as e: fail(f"Недоступен: {e}") return False +# #endregion Plugin.LlmAnalysis.AuthDiag.CheckReachability # ── 2. JWT Login ───────────────────────────────────────────── +# #region Plugin.LlmAnalysis.AuthDiag.CheckJwtLogin [C:3] [TYPE Function] +# @ingroup LlmAnalysis +# @BRIEF Attempt JWT API login across db/ldap providers. def check_jwt_login(base_url: str, username: str, password: str, verify: bool) -> str | None: banner("2. JWT API Login") with http_client(verify) as c: @@ -107,10 +129,14 @@ def check_jwt_login(base_url: str, username: str, password: str, verify: bool) - except Exception as e: fail(f"provider='{provider}' → ошибка: {e}") return None +# #endregion Plugin.LlmAnalysis.AuthDiag.CheckJwtLogin # ── 3. JWT endpoints ───────────────────────────────────────── +# #region Plugin.LlmAnalysis.AuthDiag.CheckJwtEndpoints [C:2] [TYPE Function] +# @ingroup LlmAnalysis +# @BRIEF Verify JWT bearer access to core API endpoints. def check_jwt_endpoints(base_url: str, token: str, verify: bool): banner("3. JWT — доступ к API endpoints") headers = {"Authorization": f"Bearer {token}"} @@ -122,10 +148,14 @@ def check_jwt_endpoints(base_url: str, token: str, verify: bool): else fail(f"GET {path} → HTTP {r.status_code}") except Exception as e: fail(f"GET {path} → ошибка: {e}") +# #endregion Plugin.LlmAnalysis.AuthDiag.CheckJwtEndpoints # ── 3b. CSRF token + session cookie ───────────────────────── +# #region Plugin.LlmAnalysis.AuthDiag.CheckCsrfSession [C:4] [TYPE Function] +# @ingroup LlmAnalysis +# @BRIEF Fetch CSRF token and probe anonymous/authenticated Set-Cookie session behavior. def check_csrf_session(base_url: str, token: str, verify: bool) -> tuple[str | None, dict | None]: """ GET /api/v1/security/csrf_token/ с JWT. @@ -173,10 +203,14 @@ def check_csrf_session(base_url: str, token: str, verify: bool) -> tuple[str | N warn(f"GET / ошибка: {e}") return csrf_token, csrf_session_cookie +# #endregion Plugin.LlmAnalysis.AuthDiag.CheckCsrfSession # ── 4. Guest Token ─────────────────────────────────────────── +# #region Plugin.LlmAnalysis.AuthDiag.CheckGuestToken [C:4] [TYPE Function] +# @ingroup LlmAnalysis +# @BRIEF Try guest-token issuance via three strategies: bare JWT, CSRF header, JWT+CSRF+session. def check_guest_token(base_url: str, token: str, csrf_token: str | None, csrf_session: dict | None, verify: bool, dashboard_ids: list[str] | None) -> list[str] | None: @@ -256,10 +290,14 @@ def check_guest_token(base_url: str, token: str, warn(f"dashboard #{did}: пропускаем JWT+CSRF+sess — нет csrf_token или csrf_session") return dashboard_ids +# #endregion Plugin.LlmAnalysis.AuthDiag.CheckGuestToken # ── 5. Form Login ──────────────────────────────────────────── +# #region Plugin.LlmAnalysis.AuthDiag.CheckFormLogin [C:3] [TYPE Function] +# @ingroup LlmAnalysis +# @BRIEF Attempt classic form login and capture the raw Set-Cookie header. def check_form_login(base_url: str, username: str, password: str, verify: bool) -> str | None: """POST /login/, возвращает сырой Set-Cookie или None.""" banner("5. Form Login (POST /login/)") @@ -301,10 +339,14 @@ def check_form_login(base_url: str, username: str, password: str, verify: bool) except Exception as e: fail(f"POST /login/ ошибка: {e}") return None +# #endregion Plugin.LlmAnalysis.AuthDiag.CheckFormLogin # ── 6. Session Cookie → Dashboard ──────────────────────────── +# #region Plugin.LlmAnalysis.AuthDiag.CheckSessionToDashboard [C:3] [TYPE Function] +# @ingroup LlmAnalysis +# @BRIEF Full-cycle check: session cookie against main page and dashboard load. def check_session_to_dashboard(base_url: str, set_cookie_header: str | None, verify: bool, dashboard_ids: list[str] | None): banner("6. Session Cookie → Dashboard (ПОЛНЫЙ ЦИКЛ)") @@ -358,10 +400,14 @@ def check_session_to_dashboard(base_url: str, set_cookie_header: str | None, warn(f"dashboard #{did} → HTTP {r.status_code}") except Exception as e: fail(f"dashboard #{did} → ошибка: {e}") +# #endregion Plugin.LlmAnalysis.AuthDiag.CheckSessionToDashboard # ── 7. JWT → UI dashboard ──────────────────────────────────── +# #region Plugin.LlmAnalysis.AuthDiag.CheckJwtUi [C:2] [TYPE Function] +# @ingroup LlmAnalysis +# @BRIEF Check whether JWT bearer alone can load the dashboard UI. def check_jwt_ui(base_url: str, token: str, verify: bool, dashboard_ids: list[str] | None): banner("7. JWT Bearer → Dashboard UI (без session cookie)") if not dashboard_ids: @@ -381,10 +427,14 @@ def check_jwt_ui(base_url: str, token: str, verify: bool, dashboard_ids: list[st warn(f"dashboard #{did} → HTTP {r.status_code}") except Exception as e: warn(f"dashboard #{did} → ошибка: {e}") +# #endregion Plugin.LlmAnalysis.AuthDiag.CheckJwtUi # ── MAIN ────────────────────────────────────────────────────── +# #region Plugin.LlmAnalysis.AuthDiag.Main [C:4] [TYPE Function] +# @ingroup LlmAnalysis +# @BRIEF Orchestrate all auth-path checks and print the summary verdict. def main(): parser = argparse.ArgumentParser(description="Superset Auth Diagnostics (Extended v3)") parser.add_argument("--url", required=True, help="Superset base URL") @@ -466,3 +516,5 @@ def main(): if __name__ == "__main__": main() +# #endregion Plugin.LlmAnalysis.AuthDiag.Main +# #endregion Plugin.LlmAnalysis.AuthDiag diff --git a/backend/src/plugins/llm_analysis/service.py b/backend/src/plugins/llm_analysis/service.py index dde885321..059ccab3f 100644 --- a/backend/src/plugins/llm_analysis/service.py +++ b/backend/src/plugins/llm_analysis/service.py @@ -5,901 +5,47 @@ # @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. +# @RATIONALE Decomposed per INV_7 (<400 LOC per module): screenshot pipeline lives in +# _screenshot*.py mixins, LLM transport in _llm_client_core.py, analysis paths +# in _llm_client_analysis.py, dataset health in _dataset_health.py, redaction +# in _redaction.py. This module is the stable import facade — external consumers +# (plugin.py, tests) keep importing ScreenshotService / LLMClient / +# DatasetHealthChecker / RedactionService and the timeout constants from here. # @REJECTED Keeping inline numeric timeout literals was rejected — they create a maintenance hazard where any timeout adjustment requires grep-and-fix across 1700+ lines; named constants centralize tuning and make timeout configuration auditable. -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 openai import AsyncOpenAI -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 .exceptions import ( - ProviderAuthenticationFailure, - ProviderConfigurationFailure, - ProviderFailure, - ProviderRateLimitFailure, - ProviderTransportFailure, +from ._constants import ( + DEFAULT_USER_AGENT, + HTTP_REQUEST_TIMEOUT_MS, + LLM_HTTP_TIMEOUT_S, + PLAYWRIGHT_NAVIGATION_TIMEOUT_MS, + PLAYWRIGHT_SHORT_TIMEOUT_MS, + PLAYWRIGHT_WAIT_TIMEOUT_MS, + SCREENSHOT_SERVICE_TIMEOUT_MS, ) -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 Plugin.Service.ScreenshotService [C:4] [TYPE Class] [SEMANTICS llm,screenshot,playwright] -# @defgroup LLMAnalysis Module group. -# @BRIEF Handles capturing screenshots of Superset dashboards. -# @SIDE_EFFECT Launches Playwright browser; captures screenshots to disk. -class ScreenshotService: - # #region ScreenshotService.__init__ [C:1] [TYPE Function] [SEMANTICS init] - # @BRIEF 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 [C:3] [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. - 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 [C:2] [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 _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 [C:2] [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. - 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 [C:2] [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. - 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 [C:2] [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 _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 [C:2] [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 _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 [C:3] [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. - 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.explore("Direct form login fallback skipped: csrf_token not found", error="Missing csrf_token in login form") - return False - - try: - request_context = page.context.request - except Exception as context_error: - logger.explore("Direct form login fallback skipped: request context unavailable", payload={"error": str(context_error)}, error="Browser request context unavailable") - 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.reason( - "Attempting direct form login fallback via browser context request", - payload={"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.reason( - "Direct form login fallback redirect response", - payload={"status": response_status, "url": response_url, "location": redirect_location, "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.reason( - "Direct form login fallback response", - payload={"status": response_status, "url": response_url, "login_markup": looks_like_login_page, "snippet": text_snippet}, - ) - return not looks_like_login_page - - # #endregion ScreenshotService._submit_login_via_form_post - - # #region ScreenshotService._find_login_field_locator [C:3] [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. - 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 [C:3] [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. - 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 [C:3] [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. - 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.explore( - "Primary navigation wait failed, falling back to fallback wait", - payload={"primary_wait_until": primary_wait_until, "url": url}, - error=str(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.explore("Chart stabilization wait timed out, proceeding anyway", error="Timed out waiting for charts to render") - - # #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.explore("Re-render wait timed out after viewport resize, proceeding anyway", error="Timed out waiting for charts to re-render after resize") - - # #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 [C:4] [TYPE Function] [C:4] - # @BRIEF 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.reflect("Login and navigation to dashboard successful", payload={"dashboard_id": dashboard_id}) - return browser, context, page - - # #endregion ScreenshotService._launch_and_login - - # #region ScreenshotService.capture_dashboard_chunks [C:4] [TYPE Function] [C:4] - # @BRIEF 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.reason("Found tabs at current depth", payload={"tab_count": len(found_tabs), "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.reason("Switching to tab", payload={"tab_text": tab_text, "depth": depth, "index": i}) - - 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.explore( - "Content verification timed out after tab switch", - payload={"tab_text": tab_text}, - error="Verification wait timed out", - ) - - # 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}_d{depth}.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.explore( - "CDP screenshot failed, falling back to Playwright full_page", - payload={"tab_text": tab_text}, - error=str(cdp_err), - ) - await page.screenshot(path=tab_path, full_page=True, timeout=PLAYWRIGHT_WAIT_TIMEOUT_MS) - - logger.reason("Saved screenshot for tab", payload={"tab_path": tab_path, "tab_text": tab_text}) - results.append({"tab_name": tab_text, "path": tab_path}) - - # Recurse into nested tabs - await _capture_tabs(depth + 1) - - except Exception as tab_e: - logger.explore( - "Failed to process tab", - payload={"tab_index": i, "depth": depth}, - error=str(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.reason("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.explore("CDP full-page fallback failed, using Playwright full_page", payload={}, error=str(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 [C:4] [TYPE Function] [C:4] - # @BRIEF 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.reason("Capturing dashboard screenshots", payload={"dashboard_id": 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.explore("No screenshots captured for dashboard", payload={"dashboard_id": dashboard_id}, error="All capture attempts returned empty") - return [], [] - - # 2. Convert PNGs to JPEGs for LLM - jpeg_paths = self._convert_screenshots_for_llm(png_paths, output_dir) - logger.reason( - "Converted PNGs to JPEGs for LLM analysis", - payload={"converted": len(jpeg_paths), "total": len(png_paths)}, - ) - - # 3. Archive to WebP (deletes PNGs on success) - archive_results = self._archive_screenshots_as_webp(png_paths, output_dir) - archived_count = sum(1 for r in archive_results if r.get("webp_path")) - logger.reason( - "Archived screenshots to WebP", - payload={"archived": archived_count, "total": len(archive_results)}, - ) - - # 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.explore("Failed to convert screenshot for LLM", payload={"png_path": png_path}, error=str(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.explore("Failed to archive screenshot to WebP, keeping PNG", payload={"png_path": png_path}, error=str(e)) - 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.explore("Failed to delete temporary file", payload={"path": path}, error=str(e)) - - # #endregion ScreenshotService._cleanup_temp_files - - -# #endregion Plugin.Service.ScreenshotService - -# #region Plugin.Service.ShouldRetry [C:2] [TYPE Function] [SEMANTICS llm,retry,policy] -# @BRIEF Custom retry predicate for Tenacity — excludes non-recoverable errors from LLM retry loops. -# @PRE exception is an Exception raised during LLM API call. -# @POST Returns True if the error is retryable (transport, rate limit), False for permanent errors (auth, config). -# @RELATION CALLED_BY -> [Plugin.Service.LLMClient.get_json_completion] -# @RELATION CALLED_BY -> [Plugin.Service.LLMClient.analyze_dashboard_text_batch] -# @RATIONALE Extracted to module level because it is referenced by @retry decorators in two separate methods -# (get_json_completion and analyze_dashboard_text_batch). A nested function would be fragile — -# moving either method would break the other's retry configuration. -def _should_retry(exception: Exception) -> bool: - """Custom retry predicate that excludes non-recoverable errors.""" - # Typed provider failures use their native retryability - if isinstance(exception, ProviderFailure): - return exception.retryable - # Don't retry on OpenAIAuthenticationError - if isinstance(exception, OpenAIAuthenticationError): - return False - # Don't retry on null content / model errors — retrying won't help - msg = str(exception).lower() - if "null content" in msg or "none" in msg: - return False - # Retry on rate limit errors - if isinstance(exception, RateLimitError): - return True - # For other exceptions, limit retries - return True -# #endregion Plugin.Service.ShouldRetry +from ._dataset_health import DatasetHealthChecker +from ._llm_client_analysis import LLMClientAnalysisMixin +from ._llm_client_core import LLMClientCoreMixin +from ._redaction import RedactionService +from ._screenshot import ScreenshotService + +__all__ = [ + "AsyncOpenAI", + "DEFAULT_USER_AGENT", + "DatasetHealthChecker", + "HTTP_REQUEST_TIMEOUT_MS", + "LLMClient", + "LLM_HTTP_TIMEOUT_S", + "PLAYWRIGHT_NAVIGATION_TIMEOUT_MS", + "PLAYWRIGHT_SHORT_TIMEOUT_MS", + "PLAYWRIGHT_WAIT_TIMEOUT_MS", + "RedactionService", + "SCREENSHOT_SERVICE_TIMEOUT_MS", + "ScreenshotService", + "httpx", +] # #region Plugin.Service.LLMClient [C:4] [TYPE Class] [SEMANTICS llm,client,provider,openai] @@ -908,925 +54,8 @@ def _should_retry(exception: Exception) -> bool: # @SIDE_EFFECT Makes HTTP calls to LLM provider APIs. # @RATIONALE The LLMClient abstracts provider-specific quirks behind a uniform interface: JSON response format detection, SSL verification for self-hosted providers, connection error diagnostics, image optimization for multimodal models, and payload size estimation. This encapsulation ensures analysis logic (DashboardValidationPlugin) is provider-agnostic. # @REJECTED Direct httpx/openai calls in plugin code was rejected — it would duplicate SSL/retry/error-handling logic across every analysis path. A thin wrapper without provider-specific adaptations was rejected — providers have incompatible JSON mode support and SSL requirements; the client must normalize these differences. -class LLMClient: - # #region LLMClient.__init__ [C:2] [TYPE Function] - # @BRIEF 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.reason( - "Initializing LLM client", - payload={ - "provider_type": str(provider_type), - "base_url": base_url, - "default_model": default_model, - "api_key_present": bool(self.api_key), - "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() - default_headers["X-Title"] = os.getenv("OPENROUTER_APP_NAME", "").strip() or "" - 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() - from ...core.ssl import describe_context - - ssl_desc = describe_context(ssl_verify) - logger.reason("LLM client SSL verification configured", payload={"ssl_verify": ssl_desc}) - - 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 [C:3] [TYPE Function] - # @BRIEF Resolve SSL verification flag from environment. - # @POST Returns SSLContext with system CA dir (never False — centralized SSL). - # @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= отвергнут — 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: - from ...core.ssl import system_ssl_context - - return system_ssl_context() - - # #endregion LLMClient._get_ssl_verify - - # #region LLMClient._format_connection_error [C:2] [TYPE Function] - # @BRIEF 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 [C:3] [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 _supports_json_response_format(self) -> bool: - model = (self.default_model or "").lower() - - # Free-tier models from ANY gateway often reject json_object mode - # (Nvidia NeMo free via OpenRouter or Kilo, stepfun free, etc.) - if ":free" in model: - return False - # stepfun models (even non-free) don't support json_object mode - if "stepfun/" in model or model.startswith("step-"): - return False - return True - - # #endregion LLMClient._supports_json_response_format - - # #region LLMClient._normalize_provider_error [C:3] [TYPE Function] - # @BRIEF Normalize OpenAI/HTTP exceptions into typed ProviderFailure hierarchy. - # @PRE exc is a raw exception from the OpenAI SDK or httpx client. - # @POST Returns a ProviderFailure subclass that expresses retryability. - # @SIDE_EFFECT Logs the normalization decision. - # @RATIONALE OpenAI SDK exceptions (OpenAIAuthenticationError, etc.) carry status codes and - # messages that map to typed provider errors. httpx transport errors map to - # ProviderTransportFailure. Generic exceptions with "401" patterns are caught - # via string fallback for non-OpenAI gateways. - @staticmethod - def _normalize_provider_error(exc: Exception, provider_id: str | None = None) -> ProviderFailure: - # Already a typed failure — pass through - if isinstance(exc, ProviderFailure): - return exc - - # OpenAI SDK auth error - if isinstance(exc, OpenAIAuthenticationError): - return ProviderAuthenticationFailure( - str(exc), provider_id=provider_id, status_code=401, original=exc, - ) - - # OpenAI rate limit - if isinstance(exc, RateLimitError): - return ProviderRateLimitFailure( - str(exc), provider_id=provider_id, status_code=429, original=exc, - ) - - # httpx transport errors (connection, timeout, DNS) - if isinstance(exc, httpx.TimeoutException): - return ProviderTransportFailure( - str(exc), provider_id=provider_id, original=exc, - ) - if isinstance(exc, httpx.ConnectError): - return ProviderTransportFailure( - str(exc), provider_id=provider_id, original=exc, - ) - - # HTTP status code via status_code attribute (OpenAI-like SDKs) - status_code = getattr(exc, "status_code", None) or getattr(exc, "status", None) - if status_code is not None: - status_code = int(status_code) # type: ignore[arg-type] - if status_code in (401, 403): - return ProviderAuthenticationFailure( - str(exc), provider_id=provider_id, status_code=status_code, original=exc, - ) - if status_code == 429: - return ProviderRateLimitFailure( - str(exc), provider_id=provider_id, status_code=429, original=exc, - ) - if status_code >= 500: - return ProviderTransportFailure( - str(exc), provider_id=provider_id, status_code=status_code, original=exc, - ) - - # String fallback for non-OpenAI gateways that embed an auth status in generic errors. - msg = str(exc).lower() - if "401" in msg or "403" in msg or "authentication" in msg or "unauthorized" in msg: - return ProviderAuthenticationFailure( - str(exc), - provider_id=provider_id, - status_code=401 if "401" in msg else 403 if "403" in msg else None, - original=exc, - ) - if "429" in msg or "rate limit" in msg: - return ProviderRateLimitFailure( - str(exc), provider_id=provider_id, original=exc, - ) - - # Default: assume transport-level failure (retryable) - return ProviderTransportFailure( - str(exc), provider_id=provider_id, original=exc, - ) - # #endregion LLMClient._normalize_provider_error - - # #region LLMClient.get_json_completion [C:4] [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. - @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.reason( - "Attempting LLM call", - payload={ - "model": self.default_model, - "json_mode": "on" if use_json_mode else "off", - "base_url": self.base_url, - "message_count": len(messages), - "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.explore("JSON mode failed or not supported, falling back to plain text", payload={"model": self.default_model}, error=str(e)) - response = await self.client.chat.completions.create(model=self.default_model, messages=messages) - else: - raise e - - logger.reflect("LLM API response received", payload={"response_summary": str(response)[:200]}) - except RateLimitError as e: - logger.explore("Rate limit hit on LLM call, retrying with backoff", payload={}, error=str(e)) - - # 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.explore("Failed to parse retry delay from error response", payload={}, error=str(parse_e)) - - # Add a small safety margin (0.5s) as requested - wait_time = retry_delay + 0.5 - logger.reason("Waiting before LLM retry", payload={"wait_time_seconds": wait_time}) - await asyncio.sleep(wait_time) - raise - except Exception as e: - # Normalize into typed provider exception chain - provider_exc = self._normalize_provider_error(e, provider_id=self.default_model) - logger.explore( - "LLM call failed, normalized as", - payload={"type": type(provider_exc).__name__, "status_code": provider_exc.status_code}, - error=str(provider_exc), - ) - raise provider_exc from e - - 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.reflect("Raw LLM response content received for parsing", payload={"content_length": len(content) if content else 0}) - - # LLM returned null content — likely content filter or rate limit - if content is None: - raise RuntimeError("LLM returned null content (content filter or rate limit)") - - try: - return json.loads(content) - except json.JSONDecodeError: - logger.explore("Failed to parse JSON directly, attempting to extract from code blocks", payload={}, error="JSONDecodeError on first parse attempt") - 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 [C:3] [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. - 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 [C:3] [TYPE Function] - # @BRIEF 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( - "Fetched available models from provider", - payload={"model_count": len(model_ids), "base_url": self.base_url}, - ) - return model_ids - except Exception as e: - logger.explore( - "Failed to fetch models from provider", - payload={"base_url": self.base_url, "formatted_error": self._format_connection_error(e)}, - error=str(e), - ) - raise - - # #endregion LLMClient.fetch_models - - # #region LLMClient.analyze_dashboard [C:4] [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. - # @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] - # @BRIEF 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] - # @BRIEF 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] - # @BRIEF 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] - # @BRIEF 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.explore("Image optimization failed, falling back to raw read", payload={"path": path}, error=str(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] - # @BRIEF 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] - # @BRIEF 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] - # @BRIEF Path A: send screenshots + logs to multimodal LLM, with chunking support. - # @PRE screenshot_paths is a non-empty list of paths. - # tab_labels, if provided, must have the same length as screenshot_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, - tab_labels: list[str] | 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) - tab_list_text = "\n".join(f" Screenshot {i}: {label}" for i, label in enumerate(tab_labels or [])) or "Screenshots are in order." - prompt = render_prompt( - prompt_template, - { - "logs": log_text, - "tab_list": tab_list_text, - "total_chunks": str(len(encoded_images)), - }, - ) - - # 2. Determine chunking - # Default to 8 images per chunk as a safe fallback when max_images is 0 or None - # (0 means probe failed — e.g. Kilo gateway doesn't support OpenAI image format) - DEFAULT_CHUNK_SIZE = 8 - effective_max = max_images if (max_images is not None and max_images > 0) else DEFAULT_CHUNK_SIZE - n_total = len(encoded_images) - chunk_size = effective_max if effective_max < n_total else n_total - is_chunking = chunk_size < n_total - - if is_chunking: - logger.reason( - "Chunking images for multimodal analysis", - payload={"total_images": n_total, "chunk_count": (n_total + chunk_size - 1) // chunk_size, "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.reason( - "Reducing image quality to fit context window", - payload={"pct_of_limit": estimate["pct_of_limit"], "new_quality": 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): - # Re-raise permanent provider errors (auth, config) — they apply to all chunks - normalized = self._normalize_provider_error(cr) - if not normalized.retryable: - logger.explore( - "Multimodal analysis chunk failed with permanent provider error, aborting", - payload={"chunk_index": i + 1, "error_type": type(normalized).__name__}, - error=str(cr), - ) - raise normalized from cr - # Transient chunk failures can be merged as UNKNOWN - logger.explore("Multimodal analysis chunk failed (transient)", payload={"chunk_index": i + 1, "total_chunks": len(chunks)}, error=str(cr)) - 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 (ProviderAuthenticationFailure, ProviderConfigurationFailure): - # Permanent provider errors propagate to TaskManager — no UNKNOWN swallow - raise - except Exception as e: - # Transient errors and unexpected failures → UNKNOWN (existing behavior) - normalized = self._normalize_provider_error(e) - if not normalized.retryable: - raise normalized from e - logger.explore("Failed to get multimodal analysis from LLM", payload={}, error=str(e)) - 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] - # @BRIEF 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{top}\n\nDataset health:\n{p.get("dataset_health", "")}\n\nLogs:\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 - +class LLMClient(LLMClientCoreMixin, LLMClientAnalysisMixin): + """Facade composing the provider transport core with the analysis paths.""" # #endregion Plugin.Service.LLMClient - - -# #region Plugin.Service.DatasetHealthChecker [C:3] [TYPE Class] -# @defgroup LLMAnalysis Module group. -# @BRIEF Checks dataset accessibility and KXD connectivity via Superset API. -# @LAYER Service -# @RELATION CALLS -> [Core.Init.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__ [C:2] [TYPE Function] - # @BRIEF 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 [C:2] [TYPE Function] - # @BRIEF 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 [C:3] [TYPE Function] - # @BRIEF 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 [C:3] [TYPE Function] - # @BRIEF 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 [C:3] [TYPE Function] - # @BRIEF 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 Plugin.Service.DatasetHealthChecker - - -# #region Plugin.Service.RedactionService [C:2] [TYPE Module] -# @defgroup LLMAnalysis Module group. -# @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 Plugin.Service.RedactionService - # #endregion Plugin.Service.LLMAnalysisService diff --git a/backend/src/plugins/maintenance_banner.py b/backend/src/plugins/maintenance_banner.py index 64a374b45..c491ebe8c 100644 --- a/backend/src/plugins/maintenance_banner.py +++ b/backend/src/plugins/maintenance_banner.py @@ -21,7 +21,7 @@ from ..models.maintenance import MaintenanceSettings from ..services.maintenance import end_all_maintenance, end_maintenance, start_maintenance -# #region Plugin.MaintenanceBanner.MaintenanceBannerPlugin [C:4] [TYPE Class] +# #region Plugin.MaintenanceBanner.MaintenanceBannerPlugin.Class [C:4] [TYPE Class] # @defgroup Plugin Module group. # @BRIEF Plugin for executing maintenance banner operations asynchronously via TaskManager. # @RELATION CALLS -> [start_maintenance] @@ -159,7 +159,7 @@ class MaintenanceBannerPlugin(PluginBase): finally: db.close() # #endregion Plugin.MaintenanceBanner.Execute -# #endregion Plugin.MaintenanceBanner.MaintenanceBannerPlugin +# #endregion Plugin.MaintenanceBanner.MaintenanceBannerPlugin.Class # #region Plugin.MaintenanceBanner.ReportProgress [C:2] [TYPE Function] diff --git a/backend/src/plugins/storage/plugin.py b/backend/src/plugins/storage/plugin.py index be1dc9602..475584e14 100644 --- a/backend/src/plugins/storage/plugin.py +++ b/backend/src/plugins/storage/plugin.py @@ -23,7 +23,7 @@ from ...models.storage import FileCategory, StoredFile from ...services.storage_service import StorageService -# #region Plugin.StoragePlugin [TYPE Class] +# #region Plugin.StoragePlugin.Class [TYPE Class] # @defgroup Storage Module group. # @BRIEF Implementation of the storage management plugin — thin REST adapter over StorageService. class StoragePlugin(PluginBase): @@ -274,5 +274,5 @@ class StoragePlugin(PluginBase): return fp # endregion Plugin.StoragePlugin.GetFilePath -# #endregion Plugin.StoragePlugin +# #endregion Plugin.StoragePlugin.Class # #endregion Plugin.StoragePlugin diff --git a/backend/src/plugins/translate/_batch_proc.py b/backend/src/plugins/translate/_batch_proc.py index aefc8d5ed..e637214af 100644 --- a/backend/src/plugins/translate/_batch_proc.py +++ b/backend/src/plugins/translate/_batch_proc.py @@ -34,7 +34,7 @@ from .dictionary import DictionaryManager from .events import TranslationEventLog -# #region Plugin.BatchProc.BatchProcessingService [C:4] [TYPE Class] +# #region Plugin.BatchProc.BatchProcessingService.Class [C:4] [TYPE Class] # @defgroup Translate Module group. # @BRIEF Create batch records, classify rows, process LLM calls, persist results. class BatchProcessingService: @@ -411,5 +411,5 @@ class BatchProcessingService: async def insert_batch_to_target(self, job: TranslationJob, batch_id: str, run_id: str) -> None: await insert_batch_to_target(self.db, self.config_manager, job, batch_id, run_id) # #endregion Plugin.BatchProc.InsertBatchToTarget -# #endregion Plugin.BatchProc.BatchProcessingService +# #endregion Plugin.BatchProc.BatchProcessingService.Class # #endregion Plugin.BatchProc.BatchProcessingService diff --git a/backend/src/plugins/translate/_batch_sizer.py b/backend/src/plugins/translate/_batch_sizer.py index dec81787c..90e22fbc3 100644 --- a/backend/src/plugins/translate/_batch_sizer.py +++ b/backend/src/plugins/translate/_batch_sizer.py @@ -29,7 +29,7 @@ from ._token_budget import ( from ._utils import estimate_row_tokens -# #region Plugin.BatchSizer.AdaptiveBatchSizer [C:3] [TYPE Class] +# #region Plugin.BatchSizer.AdaptiveBatchSizer.Class [C:3] [TYPE Class] # @defgroup Translate Module group. # @BRIEF Split source rows into auto-sized batches based on token budget estimates. class AdaptiveBatchSizer: @@ -226,5 +226,5 @@ class AdaptiveBatchSizer: return batches # #endregion Plugin.BatchSizer.AutoSizeBatches -# #endregion Plugin.BatchSizer.AdaptiveBatchSizer +# #endregion Plugin.BatchSizer.AdaptiveBatchSizer.Class # #endregion Plugin.BatchSizer.AdaptiveBatchSizer diff --git a/backend/src/plugins/translate/_llm_call.py b/backend/src/plugins/translate/_llm_call.py index 6e46aaf45..83bba295d 100644 --- a/backend/src/plugins/translate/_llm_call.py +++ b/backend/src/plugins/translate/_llm_call.py @@ -38,7 +38,7 @@ from .prompt_builder import ContextAwarePromptBuilder MAX_RETRIES_PER_BATCH = 3 -# #region Plugin.LlmCall.LLMTranslationService [C:4] [TYPE Class] +# #region Plugin.LlmCall.LLMTranslationService.Class [C:4] [TYPE Class] # @defgroup Translate Module group. # @BRIEF Call LLM, handle retry/truncation, parse response, persist records. class LLMTranslationService: @@ -868,5 +868,5 @@ class LLMTranslationService: def _parse_llm_response(*a, **kw): return parse_llm_response(*a, **kw) # #endregion Plugin.LlmCall.ParseLlmResponse -# #endregion Plugin.LlmCall.LLMTranslationService +# #endregion Plugin.LlmCall.LLMTranslationService.Class # #endregion Plugin.LlmCall.LLMTranslationService diff --git a/backend/src/plugins/translate/_run_service.py b/backend/src/plugins/translate/_run_service.py index 21ea3bf5a..28f08b77d 100644 --- a/backend/src/plugins/translate/_run_service.py +++ b/backend/src/plugins/translate/_run_service.py @@ -23,7 +23,7 @@ from ...models.translate import TranslationJob, TranslationRecord, TranslationRu from ._run_source import _extract_chart_data_rows, fetch_source_rows -# #region Plugin.RunService.RunExecutionService [C:4] [TYPE Class] +# #region Plugin.RunService.RunExecutionService.Class [C:4] [TYPE Class] # @defgroup Translate Module group. # @BRIEF Orchestrate full translation run: fetch data, process batches, finalize. class RunExecutionService: @@ -187,5 +187,5 @@ class RunExecutionService: lang_stat.token_count = total_tokens // num_langs lang_stat.estimated_cost = round((lang_stat.token_count / 1000) * cost_per_token, 6) self.db.flush() -# #endregion Plugin.RunService.RunExecutionService +# #endregion Plugin.RunService.RunExecutionService.Class # #endregion Plugin.RunService.RunExecutionService diff --git a/backend/src/plugins/translate/_token_budget.py b/backend/src/plugins/translate/_token_budget.py index cc4fa6470..6dde53b8a 100644 --- a/backend/src/plugins/translate/_token_budget.py +++ b/backend/src/plugins/translate/_token_budget.py @@ -14,7 +14,6 @@ # CJK ratio 1.5 — too optimistic for Qwen/DeepSeek tokenizers (actual ~1.0-1.2). # Input-only batch sizing — output budget is the primary truncation cause (finish_reason=length). -# #endregion Plugin.TokenBudget.EstimateTokenBudget # #region Plugin.TokenBudget.DEFAULTCONTEXTWINDOW [TYPE Constant] # @ingroup Translate # @BRIEF Conservative default when provider has no context_window. Local LM Studio often diff --git a/backend/src/plugins/translate/events.py b/backend/src/plugins/translate/events.py index 2eda6c839..ce2d30ce9 100644 --- a/backend/src/plugins/translate/events.py +++ b/backend/src/plugins/translate/events.py @@ -41,7 +41,7 @@ VALID_EVENT_TYPES = TERMINAL_EVENT_TYPES | { DEFAULT_RETENTION_DAYS = 90 -# #region Plugin.Events.TranslationEventLog [C:5] [TYPE Class] +# #region Plugin.Events.TranslationEventLog.Class [C:5] [TYPE Class] # @defgroup Translate Module group. # @BRIEF Structured event logging for translation operations with terminal event invariant enforcement. # @PRE Database session is available. @@ -275,5 +275,5 @@ class TranslationEventLog: # endregion Plugin.Events.GetRunEventSummary -# #endregion Plugin.Events.TranslationEventLog +# #endregion Plugin.Events.TranslationEventLog.Class # #endregion Plugin.Events.TranslationEventLog diff --git a/backend/src/plugins/translate/executor.py b/backend/src/plugins/translate/executor.py index 3428e691e..e9ca116b5 100644 --- a/backend/src/plugins/translate/executor.py +++ b/backend/src/plugins/translate/executor.py @@ -47,7 +47,7 @@ __all__ = [ ] # #endregion Plugin.Executor.TranslationExecutor -# #region Plugin.Executor.TranslationExecutor [C:4] [TYPE Class] [SEMANTICS translate,executor,orchestrator] +# #region Plugin.Executor.TranslationExecutor.Class [C:4] [TYPE Class] [SEMANTICS translate,executor,orchestrator] # @ingroup TranslationExecutor # @BRIEF Thin orchestrator for translation execution. Delegates heavy logic to sub-services for INV_7 compliance. # @PRE DB session and ConfigManager provided. @@ -570,7 +570,6 @@ class TranslationExecutor: # @RATIONALE The previous elif s == 0 unconditionally marked any zero-success run as FAILED, even when all work was legitimately skipped (new_key_only scheduled runs with 8 skipped rows). This exactly matched the prod symptom "status: FAILED, successful:0, failed:0, skipped:8". # Separately, f>0 with s>0 (e.g. LLM down but some cache hits) was silently COMPLETED — that hides LLM unavailability from operators. # @REJECTED Treating sk>0 + s==0 as failure — it would turn healthy "nothing to do" scheduled runs into red alerts and pollute history. - # @TEST_INVARIANT Pure-skip scheduled translation must end COMPLETED (see Test.Executor.FinalizeRun). # @RELATION BINDS_TO -> [Test.Executor.FinalizeRun] @staticmethod def _finalize_run(run: TranslationRun, s: int, f: int, sk: int) -> TranslationRun: @@ -778,4 +777,4 @@ class TranslationExecutor: from ._llm_call import LLMTranslationService return LLMTranslationService._parse_llm_response(response_text, expected_count, target_languages, finish_reason) # #endregion TranslationExecutor._parse_llm_response -# #endregion Plugin.Executor.TranslationExecutor +# #endregion Plugin.Executor.TranslationExecutor.Class diff --git a/backend/src/plugins/translate/metrics.py b/backend/src/plugins/translate/metrics.py index edd638daf..d46d7ffb7 100644 --- a/backend/src/plugins/translate/metrics.py +++ b/backend/src/plugins/translate/metrics.py @@ -29,7 +29,7 @@ def _optional_int(value: Any) -> int | None: # #endregion Plugin.Metrics.optional_int -# #region Plugin.Metrics.TranslationMetrics [C:3] [TYPE Class] +# #region Plugin.Metrics.TranslationMetrics.Class [C:3] [TYPE Class] # @defgroup Translate Module group. # @BRIEF Aggregate translation metrics from live events and MetricSnapshot. class TranslationMetrics: @@ -37,7 +37,7 @@ class TranslationMetrics: def __init__(self, db: Session): self.db = db - #region Plugin.Metrics.GetJobMetrics [C:3] [TYPE Function] + # #region Plugin.Metrics.GetJobMetrics [C:3] [TYPE Function] # @BRIEF Get aggregated metrics for a specific job. # @PRE job_id exists. # @POST Returns dict with metrics from events + latest snapshot. @@ -205,9 +205,9 @@ class TranslationMetrics: "next_scheduled_run": next_schedule.last_run_at.isoformat() if next_schedule and next_schedule.last_run_at else None, "per_language_metrics": per_language, } - # endregion Plugin.Metrics.GetJobMetrics + # #endregion Plugin.Metrics.GetJobMetrics - # region Plugin.Metrics.GetAllMetrics [C:2] [TYPE Function] + # #region Plugin.Metrics.GetAllMetrics [C:2] [TYPE Function] # @BRIEF Get aggregated metrics for all jobs by iterating get_job_metrics. # @POST Returns list of per-job metrics. def get_all_metrics(self) -> list[dict[str, Any]]: @@ -218,8 +218,8 @@ class TranslationMetrics: .all() ) return [self.get_job_metrics(jid[0]) for jid in job_ids if jid[0]] - # endregion Plugin.Metrics.GetAllMetrics + # #endregion Plugin.Metrics.GetAllMetrics -# #endregion Plugin.Metrics.TranslationMetrics +# #endregion Plugin.Metrics.TranslationMetrics.Class # #endregion Plugin.Metrics.TranslationMetrics diff --git a/backend/src/plugins/translate/orchestrator.py b/backend/src/plugins/translate/orchestrator.py index e483f60c6..a6e841223 100644 --- a/backend/src/plugins/translate/orchestrator.py +++ b/backend/src/plugins/translate/orchestrator.py @@ -37,7 +37,7 @@ from .orchestrator_runner import TranslationStageRunner # #endregion Plugin.Orchestrator.TranslationOrchestrator -# #region Plugin.Orchestrator.TranslationOrchestrator [C:5] [TYPE Class] +# #region Plugin.Orchestrator.TranslationOrchestrator.Class [C:5] [TYPE Class] # @defgroup Translate Module group. # @BRIEF Coordinates full translation run lifecycle: validation, execution, SQL generation, Superset submission, event logging. # @PRE DB session and config manager are available. @@ -266,4 +266,4 @@ class TranslationOrchestrator: return self._aggregator.get_run_history(job_id, page, page_size) # endregion Plugin.Orchestrator.GetRunHistory -# #endregion Plugin.Orchestrator.TranslationOrchestrator +# #endregion Plugin.Orchestrator.TranslationOrchestrator.Class diff --git a/backend/src/plugins/translate/orchestrator_retry.py b/backend/src/plugins/translate/orchestrator_retry.py index 01e0fd3bd..db4e0abf5 100644 --- a/backend/src/plugins/translate/orchestrator_retry.py +++ b/backend/src/plugins/translate/orchestrator_retry.py @@ -30,7 +30,7 @@ from .orchestrator_cancel import cancel_run as _cancel_run, retry_insert as _ret # #endregion Plugin.OrchestratorRetry.TranslationRunRetryManager -# #region Plugin.OrchestratorRetry.TranslationRunRetryManager [C:4] [TYPE Class] [SEMANTICS translate,retry,manager] +# #region Plugin.OrchestratorRetry.TranslationRunRetryManager.Class [C:4] [TYPE Class] [SEMANTICS translate,retry,manager] # @ingroup Translate # @BRIEF Manages retry of failed batches and delegates cancel/insert. # @PRE db, config, event_log provided. @@ -189,4 +189,4 @@ class TranslationRunRetryManager: return _cancel_run(self.db, self.event_log, self.current_user, run_id) # #endregion TranslationRunRetryManager.cancel_run -# #endregion Plugin.OrchestratorRetry.TranslationRunRetryManager +# #endregion Plugin.OrchestratorRetry.TranslationRunRetryManager.Class diff --git a/backend/src/plugins/translate/orchestrator_sql_rows.py b/backend/src/plugins/translate/orchestrator_sql_rows.py index 92281a5ce..8aa643362 100644 --- a/backend/src/plugins/translate/orchestrator_sql_rows.py +++ b/backend/src/plugins/translate/orchestrator_sql_rows.py @@ -81,11 +81,6 @@ INSERT_RESULT_SKIPPED = "skipped" # PostgreSQL conflict_target, leading to "ON CONFLICT DO UPDATE cannot affect row a second time" # errors at the DB level. The safe approach is to fail fast and require target_language_column # to be included in target_key_cols for MERGE multi-language configurations. -# @TEST_INVARIANT dedup_merge_key_matches_conflict_target -> VERIFIED_BY: [ -# test_merge_dedup_preserves_language_rows, -# test_merge_dedup_collapses_single_lang, -# test_merge_precondition_fails_without_lang_in_key -# ] def dedup_rows_for_merge( rows: list[dict[str, object]], key_cols: list[str] | None, diff --git a/backend/src/plugins/translate/preview.py b/backend/src/plugins/translate/preview.py index 0c6247ac6..068af4ebb 100644 --- a/backend/src/plugins/translate/preview.py +++ b/backend/src/plugins/translate/preview.py @@ -43,7 +43,7 @@ from .preview_response_parser import ( from .preview_review import PreviewSessionManager -# #region Plugin.Preview.TranslationPreview [C:4] [TYPE Class] +# #region Plugin.Preview.TranslationPreview.Class [C:4] [TYPE Class] # @defgroup Translate Module group. # @BRIEF Manages preview lifecycle: fetch sample rows, call LLM, manage row-level approve/edit/reject, accept gate. # @PRE Database session and config manager are available. @@ -292,7 +292,7 @@ class TranslationPreview: def get_preview_session(self, job_id: str) -> dict[str, Any]: return self._session_mgr.get_preview_session(job_id) -# #endregion Plugin.Preview.TranslationPreview +# #endregion Plugin.Preview.TranslationPreview.Class # Re-export for backward compatibility # #endregion Plugin.Preview.TranslationPreview diff --git a/backend/src/plugins/translate/prompt_builder.py b/backend/src/plugins/translate/prompt_builder.py index 70417ffd4..b7ae77bb0 100644 --- a/backend/src/plugins/translate/prompt_builder.py +++ b/backend/src/plugins/translate/prompt_builder.py @@ -15,7 +15,7 @@ import json from typing import Any -# #region Plugin.PromptBuilder.ContextAwarePromptBuilder [C:2] [TYPE Class] +# #region Plugin.PromptBuilder.ContextAwarePromptBuilder.Class [C:2] [TYPE Class] # @defgroup Translate Module group. # @BRIEF Build LLM prompts with context-aware dictionary entries and similarity-based priority. @@ -149,7 +149,7 @@ class ContextAwarePromptBuilder: ContextAwarePromptBuilder.render_entry(entry, priority, row_context) for entry, priority in results ] -# #endregion Plugin.PromptBuilder.ContextAwarePromptBuilder +# #endregion Plugin.PromptBuilder.ContextAwarePromptBuilder.Class # #endregion Plugin.PromptBuilder.ContextAwarePromptBuilder diff --git a/backend/src/plugins/translate/scheduler.py b/backend/src/plugins/translate/scheduler.py index 4960f3647..fec901762 100644 --- a/backend/src/plugins/translate/scheduler.py +++ b/backend/src/plugins/translate/scheduler.py @@ -76,7 +76,7 @@ def _ensure_aware(dt: datetime | None) -> datetime | None: # #endregion Plugin.Scheduler.EnsureAware -# #region Plugin.Scheduler.TranslationScheduler [C:4] [TYPE Class] +# #region Plugin.Scheduler.TranslationScheduler.Class [C:4] [TYPE Class] # @defgroup Translate Module group. # @BRIEF CRUD for TranslationSchedule rows + APScheduler registration wrappers. class TranslationScheduler: @@ -289,7 +289,7 @@ class TranslationScheduler: # endregion Plugin.Scheduler.GetNextExecutions -# #endregion Plugin.Scheduler.TranslationScheduler +# #endregion Plugin.Scheduler.TranslationScheduler.Class # #region Plugin.Scheduler.ExecuteScheduledTranslation [C:4] [TYPE Function] diff --git a/backend/src/plugins/translate/service.py b/backend/src/plugins/translate/service.py index b9ad5074c..ba0a86233 100644 --- a/backend/src/plugins/translate/service.py +++ b/backend/src/plugins/translate/service.py @@ -26,7 +26,7 @@ from .service_datasource import fetch_datasource_metadata from .service_utils import _extract_dialect -# #region Plugin.Service.TranslateJobService [TYPE Class] +# #region Plugin.Service.TranslateJobService.Class [TYPE Class] # @defgroup Translate Module group. # @BRIEF Service for translation job CRUD with validation and Superset integration. class TranslateJobService: @@ -313,7 +313,7 @@ class TranslateJobService: }) return result # endregion Plugin.Service.FetchAvailableDatasources -# #endregion Plugin.Service.TranslateJobService +# #endregion Plugin.Service.TranslateJobService.Class # Re-exports for backward compatibility diff --git a/backend/src/plugins/translate/sql_generator.py b/backend/src/plugins/translate/sql_generator.py index 5e2efb9a1..6ec3cc39d 100644 --- a/backend/src/plugins/translate/sql_generator.py +++ b/backend/src/plugins/translate/sql_generator.py @@ -249,7 +249,7 @@ def generate_upsert_sql( # #endregion Plugin.SqlGenerator.GenerateUpsertSql -# #region Plugin.SqlGenerator.SQLGenerator [C:3] [TYPE Class] +# #region Plugin.SqlGenerator.SQLGenerator.Class [C:3] [TYPE Class] # @defgroup Translate Module group. # @BRIEF Generate safe, dialect-appropriate SQL INSERT/UPSERT statements. # @PRE Job has target_schema, target_table, key columns configured. @@ -427,5 +427,5 @@ class SQLGenerator: # endregion SQLGenerator.generate_batch -# #endregion Plugin.SqlGenerator.SQLGenerator +# #endregion Plugin.SqlGenerator.SQLGenerator.Class # #endregion Plugin.SqlGenerator.SQLGenerator diff --git a/backend/src/plugins/translate/superset_executor.py b/backend/src/plugins/translate/superset_executor.py index eef67a32c..9fdeaacde 100644 --- a/backend/src/plugins/translate/superset_executor.py +++ b/backend/src/plugins/translate/superset_executor.py @@ -21,7 +21,7 @@ from ...core.superset_client import SupersetClient from ...core.utils.client_registry import get_superset_client -# #region Plugin.SupersetExecutor.SupersetSqlLabExecutor [C:4] [TYPE Class] +# #region Plugin.SupersetExecutor.SupersetSqlLabExecutor.Class [C:4] [TYPE Class] # @defgroup Translate Module group. # @BRIEF Submit SQL to Superset SQL Lab API with polling and status tracking. # @PRE Valid environment ID and ConfigManager. @@ -436,5 +436,5 @@ class SupersetSqlLabExecutor: # endregion Plugin.SupersetExecutor.GetQueryResults -# #endregion Plugin.SupersetExecutor.SupersetSqlLabExecutor +# #endregion Plugin.SupersetExecutor.SupersetSqlLabExecutor.Class # #endregion Plugin.SupersetExecutor.SupersetSqlLabExecutor diff --git a/backend/src/schemas/dashboard_testing/__init__.py b/backend/src/schemas/dashboard_testing/__init__.py index 8035ba836..f1790ad5c 100644 --- a/backend/src/schemas/dashboard_testing/__init__.py +++ b/backend/src/schemas/dashboard_testing/__init__.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas [C:5] [TYPE Module] [SEMANTICS baseline,dashboard-testing,dto,schema] +# #region DashboardTesting.Schemas [C:5] [TYPE Module] [SEMANTICS baseline,dashboard-testing,dto,schema] # @defgroup DashboardTesting Pydantic DTOs for Superset-native dashboard query inspection, filter normalization, execution, comparison, and baseline lifecycle. # @LAYER DTO # @RELATION DEPENDS_ON -> [SupersetBaselineEngine.DataModel] @@ -146,4 +146,4 @@ from .verification import ( VerificationRunRequest as VerificationRunRequest, ) -#endregion DashboardTesting.Schemas +# #endregion DashboardTesting.Schemas diff --git a/backend/src/schemas/dashboard_testing/candidates.py b/backend/src/schemas/dashboard_testing/candidates.py index 1643eeb29..71ea9af6c 100644 --- a/backend/src/schemas/dashboard_testing/candidates.py +++ b/backend/src/schemas/dashboard_testing/candidates.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas.Candidates [C:3] [TYPE Module] [SEMANTICS baseline,candidates,dto] +# #region DashboardTesting.Schemas.Candidates [C:3] [TYPE Module] [SEMANTICS baseline,candidates,dto] # @defgroup DashboardTesting.Candidates Candidate request, response, and approval gate schemas. # @LAYER DTO @@ -269,5 +269,5 @@ class ApprovalConsumeResponse(BaseModel): release_commit_hash: str = Field(description="40-char git commit hash") # #endregion DashboardTesting.Schemas.ApprovalConsumeResponse -#endregion DashboardTesting.Schemas.Candidates +# #endregion DashboardTesting.Schemas.Candidates diff --git a/backend/src/schemas/dashboard_testing/capture.py b/backend/src/schemas/dashboard_testing/capture.py index 24fb164fd..273369c66 100644 --- a/backend/src/schemas/dashboard_testing/capture.py +++ b/backend/src/schemas/dashboard_testing/capture.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas.Capture [C:2] [TYPE Module] [SEMANTICS baseline,capture,dto] +# #region DashboardTesting.Schemas.Capture [C:2] [TYPE Module] [SEMANTICS baseline,capture,dto] # @defgroup DashboardTesting.Capture Server-side capture request schema for metric candidates. # @LAYER DTO # @RELATION DEPENDS_ON -> [DashboardTesting.Schemas.Candidates] @@ -69,4 +69,4 @@ class CaptureCandidateResponse(BaseModel): source_response_hash: str = Field(description="SHA-256 of the raw Superset response bytes") # #endregion DashboardTesting.Schemas.CaptureCandidateResponse -#endregion DashboardTesting.Schemas.Capture +# #endregion DashboardTesting.Schemas.Capture diff --git a/backend/src/schemas/dashboard_testing/catalog.py b/backend/src/schemas/dashboard_testing/catalog.py index 1eebb18d7..65172e00c 100644 --- a/backend/src/schemas/dashboard_testing/catalog.py +++ b/backend/src/schemas/dashboard_testing/catalog.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas.Catalog [C:3] [TYPE Module] [SEMANTICS baseline,catalog,dto] +# #region DashboardTesting.Schemas.Catalog [C:3] [TYPE Module] [SEMANTICS baseline,catalog,dto] # @defgroup DashboardTesting.Catalog Baseline entry, immutability block, and catalog container schemas. # @LAYER DTO @@ -175,4 +175,4 @@ class BaselineCatalog(BaseModel): warnings: list[Warning] = Field(default_factory=list) # #endregion DashboardTesting.Schemas.BaselineCatalog -#endregion DashboardTesting.Schemas.Catalog +# #endregion DashboardTesting.Schemas.Catalog diff --git a/backend/src/schemas/dashboard_testing/common.py b/backend/src/schemas/dashboard_testing/common.py index 69748b468..934c535e8 100644 --- a/backend/src/schemas/dashboard_testing/common.py +++ b/backend/src/schemas/dashboard_testing/common.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas.Common [C:1] [TYPE Module] [SEMANTICS baseline,common,dto] +# #region DashboardTesting.Schemas.Common [C:1] [TYPE Module] [SEMANTICS baseline,common,dto] # @defgroup DashboardTesting.Common Shared DTOs — Warning, Provenance. # @LAYER DTO @@ -38,4 +38,4 @@ class ApprovalInfo(BaseModel): at: datetime = Field(description="Timestamp of approval") # #endregion DashboardTesting.Schemas.ApprovalInfo -#endregion DashboardTesting.Schemas.Common +# #endregion DashboardTesting.Schemas.Common diff --git a/backend/src/schemas/dashboard_testing/enums.py b/backend/src/schemas/dashboard_testing/enums.py index d76362bf4..ea1594f6d 100644 --- a/backend/src/schemas/dashboard_testing/enums.py +++ b/backend/src/schemas/dashboard_testing/enums.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas.Enums [C:1] [TYPE Module] [SEMANTICS baseline,enum] +# #region DashboardTesting.Schemas.Enums [C:1] [TYPE Module] [SEMANTICS baseline,enum] # @defgroup DashboardTesting.Enums Core enums for dashboard testing schemas. # @LAYER DTO # @INVARIANT All enums are StrEnum for JSON serialization compatibility. @@ -112,4 +112,4 @@ class DiffKind(StrEnum): TIME_GRAIN_CHANGED = "time_grain_changed" # #endregion DashboardTesting.Schemas.DiffKind -#endregion DashboardTesting.Schemas.Enums +# #endregion DashboardTesting.Schemas.Enums diff --git a/backend/src/schemas/dashboard_testing/execution.py b/backend/src/schemas/dashboard_testing/execution.py index 220233bd7..8059d949d 100644 --- a/backend/src/schemas/dashboard_testing/execution.py +++ b/backend/src/schemas/dashboard_testing/execution.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas.Execution [C:1] [TYPE Module] [SEMANTICS baseline,execution,dto] +# #region DashboardTesting.Schemas.Execution [C:1] [TYPE Module] [SEMANTICS baseline,execution,dto] # @defgroup DashboardTesting.Execution Query execution request schema. # @LAYER DTO @@ -26,4 +26,4 @@ class ExecuteQueryRequest(BaseModel): max_rows: int = Field(default=10000, le=10000, description="Bounded result limit") # #endregion DashboardTesting.Schemas.ExecuteQueryRequest -#endregion DashboardTesting.Schemas.Execution +# #endregion DashboardTesting.Schemas.Execution diff --git a/backend/src/schemas/dashboard_testing/filters.py b/backend/src/schemas/dashboard_testing/filters.py index a34118b8b..7891083da 100644 --- a/backend/src/schemas/dashboard_testing/filters.py +++ b/backend/src/schemas/dashboard_testing/filters.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas.Filters [C:2] [TYPE Module] [SEMANTICS baseline,filter,dto] +# #region DashboardTesting.Schemas.Filters [C:2] [TYPE Module] [SEMANTICS baseline,filter,dto] # @defgroup DashboardTesting.Filters Filter normalization schemas — FilterValue, NormalizedFilter, NormalizedFilterContext. # @LAYER DTO @@ -56,4 +56,4 @@ class NormalizeFiltersRequest(BaseModel): query_model_fingerprint: str | None = None # #endregion DashboardTesting.Schemas.NormalizeFiltersRequest -#endregion DashboardTesting.Schemas.Filters +# #endregion DashboardTesting.Schemas.Filters diff --git a/backend/src/schemas/dashboard_testing/inheritance.py b/backend/src/schemas/dashboard_testing/inheritance.py index a56826041..f3248ede6 100644 --- a/backend/src/schemas/dashboard_testing/inheritance.py +++ b/backend/src/schemas/dashboard_testing/inheritance.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas.Inheritance [C:3] [TYPE Module] [SEMANTICS baseline,inheritance,dto,plan] +# #region DashboardTesting.Schemas.Inheritance [C:3] [TYPE Module] [SEMANTICS baseline,inheritance,dto,plan] # @defgroup DashboardTesting.Inheritance Request/response DTOs for baseline inheritance planning and execution. # @LAYER DTO # @RELATION DEPENDS_ON -> [DashboardTesting.Schemas.Catalog] @@ -99,4 +99,4 @@ class InheritancePlan(BaseModel): new_entries: list[dict[str, Any]] = Field(default_factory=list, description="New entries needing fresh capture") # #endregion DashboardTesting.Schemas.InheritancePlan -#endregion DashboardTesting.Schemas.Inheritance +# #endregion DashboardTesting.Schemas.Inheritance diff --git a/backend/src/schemas/dashboard_testing/query_model.py b/backend/src/schemas/dashboard_testing/query_model.py index 0b16295a6..4850bf4af 100644 --- a/backend/src/schemas/dashboard_testing/query_model.py +++ b/backend/src/schemas/dashboard_testing/query_model.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas.QueryModel [C:3] [TYPE Module] [SEMANTICS baseline,query-model,dto] +# #region DashboardTesting.Schemas.QueryModel [C:3] [TYPE Module] [SEMANTICS baseline,query-model,dto] # @defgroup DashboardTesting.QueryModel Structured chart/dashboard/filter metadata extracted from Superset. # @LAYER DTO @@ -134,4 +134,4 @@ class DashboardQueryModel(BaseModel): query_model_fingerprint: str = Field(description="Deterministic hash of the query model structure") # #endregion DashboardTesting.Schemas.DashboardQueryModel -#endregion DashboardTesting.Schemas.QueryModel +# #endregion DashboardTesting.Schemas.QueryModel diff --git a/backend/src/schemas/dashboard_testing/results.py b/backend/src/schemas/dashboard_testing/results.py index 5da172ce4..d7d29c6ae 100644 --- a/backend/src/schemas/dashboard_testing/results.py +++ b/backend/src/schemas/dashboard_testing/results.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas.Results [C:2] [TYPE Module] [SEMANTICS baseline,result,comparison,dto] +# #region DashboardTesting.Schemas.Results [C:2] [TYPE Module] [SEMANTICS baseline,result,comparison,dto] # @defgroup DashboardTesting.Results Normalized values, comparison policies, and comparison results. # @LAYER DTO @@ -100,7 +100,7 @@ class ComparisonResult(BaseModel): evidence_refs: list[str] = Field(default_factory=list, description="036 evidence artifact references") # #endregion DashboardTesting.Schemas.ComparisonResult -#endregion DashboardTesting.Schemas.Results +# #endregion DashboardTesting.Schemas.Results # Resolve forward reference for ComparisonPolicy.per_column ComparisonPolicy.model_rebuild() diff --git a/backend/src/schemas/dashboard_testing/scenario_registry.py b/backend/src/schemas/dashboard_testing/scenario_registry.py index a72653481..779737eb5 100644 --- a/backend/src/schemas/dashboard_testing/scenario_registry.py +++ b/backend/src/schemas/dashboard_testing/scenario_registry.py @@ -1,4 +1,4 @@ -#region ScenarioRegistry.Schemas [C:2] [TYPE Module] [SEMANTICS scenario,registry,dto,response,schema] +# #region ScenarioRegistry.Schemas [C:2] [TYPE Module] [SEMANTICS scenario,registry,dto,response,schema] # @defgroup ScenarioRegistry Pydantic response DTOs for the Scenario Registry (042). # @LAYER DTO # @RELATION DEPENDS_ON -> [ScenarioRegistry.DataModel] @@ -218,4 +218,4 @@ class ScenarioEditorProposalSaveRequest(BaseModel): agent_action_id: str | None = None # #endregion ScenarioEditor.Schemas.ProposalSaveRequest -#endregion ScenarioRegistry.Schemas +# #endregion ScenarioRegistry.Schemas diff --git a/backend/src/schemas/dashboard_testing/structure_diff.py b/backend/src/schemas/dashboard_testing/structure_diff.py index e0c196c96..ecfe343e6 100644 --- a/backend/src/schemas/dashboard_testing/structure_diff.py +++ b/backend/src/schemas/dashboard_testing/structure_diff.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas.StructureDiff [C:2] [TYPE Module] [SEMANTICS baseline,structure-diff,dto] +# #region DashboardTesting.Schemas.StructureDiff [C:2] [TYPE Module] [SEMANTICS baseline,structure-diff,dto] # @defgroup DashboardTesting.StructureDiff Dashboard structure diff schemas — request + response. # @LAYER DTO # @RELATION DEPENDS_ON -> [DashboardTesting.Schemas.Enums] @@ -53,7 +53,7 @@ class StructureChange(BaseModel): # #endregion DashboardTesting.Schemas.StructureChange -# #region DashboardTesting.Schemas.StructureDiff [C:3] [TYPE Class] [SEMANTICS baseline,structure-diff,release] +# #region DashboardTesting.Schemas.StructureDiff.Class [C:3] [TYPE Class] [SEMANTICS baseline,structure-diff,release] class StructureDiff(BaseModel): model_config = ConfigDict(extra="forbid") @@ -64,7 +64,7 @@ class StructureDiff(BaseModel): changes: list[StructureChange] = Field(default_factory=list) summary: dict[str, int] = Field(default_factory=dict, description="Counts by severity (critical, warning, info, pass)") blocked: bool = Field(default=False, description="True if critical changes block progression") -# #endregion DashboardTesting.Schemas.StructureDiff +# #endregion DashboardTesting.Schemas.StructureDiff.Class # #region DashboardTesting.Schemas.SnapshotCaptureResponse [C:3] [TYPE Class] [SEMANTICS baseline,structure-diff,capture,response,provenance] @@ -87,4 +87,4 @@ class SnapshotCaptureResponse(BaseModel): warnings: int = Field(default=0, description="Number of warnings from inspection") # #endregion DashboardTesting.Schemas.SnapshotCaptureResponse -#endregion DashboardTesting.Schemas.StructureDiff +# #endregion DashboardTesting.Schemas.StructureDiff diff --git a/backend/src/schemas/dashboard_testing/structure_snapshot.py b/backend/src/schemas/dashboard_testing/structure_snapshot.py index e58f1d8dc..1c1a90521 100644 --- a/backend/src/schemas/dashboard_testing/structure_snapshot.py +++ b/backend/src/schemas/dashboard_testing/structure_snapshot.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas.StructureSnapshot [C:4] [TYPE Module] [SEMANTICS baseline,structure-snapshot,dto,release-bound] +# #region DashboardTesting.Schemas.StructureSnapshot [C:4] [TYPE Module] [SEMANTICS baseline,structure-snapshot,dto,release-bound] # @defgroup DashboardTesting.StructureSnapshot Release-bound snapshot capture + diff schemas. # @LAYER DTO # @RELATION DEPENDS_ON -> [DashboardTesting.Schemas.QueryModel] @@ -119,4 +119,4 @@ class ProvenanceEnvelope(BaseModel): environment_id: str # #endregion DashboardTesting.Schemas.ProvenanceEnvelope -#endregion DashboardTesting.Schemas.StructureSnapshot +# #endregion DashboardTesting.Schemas.StructureSnapshot diff --git a/backend/src/schemas/dashboard_testing/verification.py b/backend/src/schemas/dashboard_testing/verification.py index a8bbf0567..9b20a9313 100644 --- a/backend/src/schemas/dashboard_testing/verification.py +++ b/backend/src/schemas/dashboard_testing/verification.py @@ -1,4 +1,4 @@ -#region DashboardTesting.Schemas.Verification [C:3] [TYPE Module] [SEMANTICS baseline,verification,dto] +# #region DashboardTesting.Schemas.Verification [C:3] [TYPE Module] [SEMANTICS baseline,verification,dto] # @defgroup DashboardTesting.Verification Verification run schemas — request + response + per-category outcomes. # @LAYER DTO # @RELATION DEPENDS_ON -> [DashboardTesting.Schemas.StructureDiff] @@ -123,4 +123,4 @@ class VerificationRun(BaseModel): created_by: str = Field(default="system", description="Actor that created the run") # #endregion DashboardTesting.Schemas.VerificationRun -#endregion DashboardTesting.Schemas.Verification +# #endregion DashboardTesting.Schemas.Verification diff --git a/backend/src/scripts/prepare_database.py b/backend/src/scripts/prepare_database.py index 1af01348e..daeb2d33c 100644 --- a/backend/src/scripts/prepare_database.py +++ b/backend/src/scripts/prepare_database.py @@ -1,3 +1,8 @@ +# #region Scripts.PrepareDatabase [C:3] [TYPE Module] [SEMANTICS database,migration,alembic,reset] +# @defgroup Scripts Module group. +# @BRIEF Serialize optional schema reset and Alembic upgrade under one DB lock. +# @LAYER Infrastructure + """Serialize optional schema reset and Alembic upgrade under one DB lock.""" from __future__ import annotations @@ -19,17 +24,29 @@ _MIGRATION_LOCK_ID = 481920260824 _BASELINE_REVISION = "0001_baseline" +# #region Scripts.PrepareDatabase.Enabled [C:2] [TYPE Function] +# @ingroup Scripts +# @BRIEF Parse truthy env flag values. def _enabled(value: str | None) -> bool: return str(value or "").strip().lower() in {"1", "true", "yes", "y"} +# #endregion Scripts.PrepareDatabase.Enabled +# #region Scripts.PrepareDatabase.RevisionIsKnown [C:2] [TYPE Function] +# @ingroup Scripts +# @BRIEF Check whether a revision string exists in the Alembic script directory. def _revision_is_known(config: Config, revision: str) -> bool: try: return ScriptDirectory.from_config(config).get_revision(revision) is not None except (CommandError, ResolutionError): return False +# #endregion Scripts.PrepareDatabase.RevisionIsKnown +# #region Scripts.PrepareDatabase.MaybeReset [C:4] [TYPE Function] +# @ingroup Scripts +# @BRIEF One-shot destructive schema reset gated by RESET_DATABASE_SCHEMA and orphaned-revision detection. +# @SIDE_EFFECT Drops and recreates the public schema when reset conditions are met. def _maybe_reset(connection, config: Config) -> None: reset_enabled = _enabled(os.getenv("RESET_DATABASE_SCHEMA")) sys.stderr.write( @@ -67,8 +84,12 @@ def _maybe_reset(connection, config: Config) -> None: f"[database] {'Orphaned' if orphaned_revision else 'Legacy'} Alembic revision " f"{revision or ''} reset successfully\n" ) +# #endregion Scripts.PrepareDatabase.MaybeReset +# #region Scripts.PrepareDatabase.PrepareDatabase [C:4] [TYPE Function] +# @ingroup Scripts +# @BRIEF Run alembic upgrade head under a PostgreSQL advisory lock. def prepare_database() -> None: engine = create_engine(database_url(), pool_pre_ping=True) try: @@ -96,3 +117,5 @@ def prepare_database() -> None: if __name__ == "__main__": prepare_database() +# #endregion Scripts.PrepareDatabase.PrepareDatabase +# #endregion Scripts.PrepareDatabase diff --git a/backend/src/scripts/reencrypt.py b/backend/src/scripts/reencrypt.py index 69984282d..725b10a9b 100644 --- a/backend/src/scripts/reencrypt.py +++ b/backend/src/scripts/reencrypt.py @@ -18,7 +18,6 @@ # Usage: # OLD_ENCRYPTION_KEY= NEW_ENCRYPTION_KEY= python -m src.scripts.reencrypt # OLD_ENCRYPTION_KEY= NEW_ENCRYPTION_KEY= python -m src.scripts.reencrypt --dry-run -# #endregion Scripts.Reencrypt import argparse from datetime import UTC, datetime @@ -41,13 +40,20 @@ Base = declarative_base() # ── Fernet helpers ──────────────────────────────────────────────────── +# #region Scripts.Reencrypt.MakeFernet [C:2] [TYPE Function] +# @ingroup Scripts +# @BRIEF Build a Fernet instance or abort with a clear error on invalid key. def _make_fernet(key_b64: str) -> Fernet: try: return Fernet(key_b64.encode()) except Exception as e: sys.exit(f"ERROR: Invalid Fernet key: {e}") +# #endregion Scripts.Reencrypt.MakeFernet +# #region Scripts.Reencrypt.ReencryptValue [C:3] [TYPE Function] +# @ingroup Scripts +# @BRIEF Decrypt with old key, encrypt with new key. Returns None on failure. def _reencrypt_value(value: str, old_fernet: Fernet, new_fernet: Fernet) -> str | None: """Decrypt with old key, encrypt with new key. Returns None on failure.""" if not is_fernet_token(value): @@ -59,6 +65,7 @@ def _reencrypt_value(value: str, old_fernet: Fernet, new_fernet: Fernet) -> str print(f" ✗ Decryption failed: {e}") return None return new_fernet.encrypt(plaintext.encode()).decode() +# #endregion Scripts.Reencrypt.ReencryptValue # ── Report helpers ──────────────────────────────────────────────────── @@ -66,14 +73,22 @@ def _reencrypt_value(value: str, old_fernet: Fernet, new_fernet: Fernet) -> str _report: list[str] = [] +# #region Scripts.Reencrypt.Report [C:2] [TYPE Function] +# @ingroup Scripts +# @BRIEF Append a message to the run report and echo it to stdout. def _r(msg: str) -> None: _report.append(msg) print(msg) +# #endregion Scripts.Reencrypt.Report # ── Main ────────────────────────────────────────────────────────────── +# #region Scripts.Reencrypt.Main [C:4] [TYPE Function] +# @ingroup Scripts +# @BRIEF Orchestrate key rotation across app_configurations and llm_providers. +# @SIDE_EFFECT Reads/writes app_configurations payload and llm_providers table unless --dry-run. def main() -> None: parser = argparse.ArgumentParser( description="Re-encrypt all stored secrets with a new Fernet ENCRYPTION_KEY." @@ -114,10 +129,12 @@ def main() -> None: # ── Step 1: Environment passwords (AppConfigRecord.payload.environments) ── _r("── Environment passwords (ConfigManager) ──") + # #region Scripts.Reencrypt.Main.AppConfigRecord [C:1] [TYPE Class] class AppConfigRecord(Base): __tablename__ = "app_configurations" id = Column(String, primary_key=True) payload = Column(Text) + # #endregion Scripts.Reencrypt.Main.AppConfigRecord total_env_passwords = 0 reencrypted_env = 0 @@ -156,10 +173,12 @@ def main() -> None: # ── Step 2: LLM Provider API keys ────────────────────────────── _r("── LLM Provider API keys ──") + # #region Scripts.Reencrypt.Main.LLMProvider [C:1] [TYPE Class] class LLMProvider(Base): __tablename__ = "llm_providers" id = Column(String, primary_key=True) api_key = Column(String) + # #endregion Scripts.Reencrypt.Main.LLMProvider total_providers = 0 reencrypted_keys = 0 @@ -201,3 +220,5 @@ def main() -> None: if __name__ == "__main__": main() +# #endregion Scripts.Reencrypt.Main +# #endregion Scripts.Reencrypt diff --git a/backend/src/scripts/test_dataset_dashboard_relations.py b/backend/src/scripts/test_dataset_dashboard_relations.py index 8c23927c6..98620cf36 100644 --- a/backend/src/scripts/test_dataset_dashboard_relations.py +++ b/backend/src/scripts/test_dataset_dashboard_relations.py @@ -172,4 +172,3 @@ if __name__ == "__main__": inspect_dashboard_dataset_relations() # #endregion Test.DatasetDashboardRelations.TestDatasetDashboardRelationsScript -# #endregion test_dataset_dashboard_relations_script diff --git a/backend/src/services/clean_release/compliance_execution_service.py b/backend/src/services/clean_release/compliance_execution_service.py index cba001c10..8024a8686 100644 --- a/backend/src/services/clean_release/compliance_execution_service.py +++ b/backend/src/services/clean_release/compliance_execution_service.py @@ -55,7 +55,7 @@ class ComplianceExecutionResult: # #endregion Services.ComplianceExecutionService.ComplianceExecutionResult -# #region Services.ComplianceExecutionService [TYPE Class] +# #region Services.ComplianceExecutionService.Class [TYPE Class] # @defgroup Services Module group. # @BRIEF Execute clean-release compliance lifecycle over trusted snapshots and immutable evidence. # @PRE Database session active, candidate registered @@ -230,6 +230,6 @@ class ComplianceExecutionService: # endregion Services.ComplianceExecutionService.ExecuteRun -# #endregion Services.ComplianceExecutionService +# #endregion Services.ComplianceExecutionService.Class # #endregion Services.ComplianceExecutionService diff --git a/backend/src/services/clean_release/compliance_orchestrator.py b/backend/src/services/clean_release/compliance_orchestrator.py index 538ee40d2..a3ef330a3 100644 --- a/backend/src/services/clean_release/compliance_orchestrator.py +++ b/backend/src/services/clean_release/compliance_orchestrator.py @@ -6,12 +6,6 @@ # @RELATION DEPENDS_ON -> [Services.Repository.RepositoryRelations] # @RELATION DEPENDS_ON -> [Models.CleanRelease.CleanReleaseModels] # @INVARIANT COMPLIANT is impossible when any mandatory stage fails. -# @TEST_CONTRACT ComplianceCheckRun -> ComplianceCheckRun -# @TEST_FIXTURE compliant_candidate -> file:backend/tests/fixtures/clean_release/fixtures_clean_release.json -# @TEST_EDGE stage_failure_blocks_release -> Mandatory stage returns FAIL and final status becomes BLOCKED -# @TEST_EDGE missing_stage_result -> Finalization with incomplete/empty mandatory stage set must not produce COMPLIANT -# @TEST_EDGE report_generation_error -> Downstream reporting failure does not alter orchestrator status derivation contract -# @TEST_INVARIANT compliant_requires_all_mandatory_pass -> VERIFIED_BY: [stage_failure_blocks_release] # @PRE ManifestService and PolicyEngine are available # @POST OrchestrationResult with compliance status # @SIDE_EFFECT Triggers compliance checks; may modify manifest state diff --git a/backend/src/services/clean_release/policy_engine.py b/backend/src/services/clean_release/policy_engine.py index 6f09841ab..507b21483 100644 --- a/backend/src/services/clean_release/policy_engine.py +++ b/backend/src/services/clean_release/policy_engine.py @@ -49,13 +49,6 @@ class SourceValidationResult: # @defgroup Services Module group. # @PRE Active policy exists and is internally consistent. # @POST Deterministic classification and source validation are available. -# @TEST_CONTRACT CandidateEvaluationInput -> PolicyValidationResult|SourceValidationResult -# @TEST_SCENARIO policy_valid -> Enterprise clean policy with matching registry returns ok=True -# @TEST_FIXTURE policy_enterprise_clean -> file:backend/tests/fixtures/clean_release/fixtures_clean_release.json -# @TEST_EDGE missing_registry_ref -> policy has empty registry_snapshot_id -# @TEST_EDGE conflicting_registry -> policy registry ref does not match registry id -# @TEST_EDGE external_endpoint -> endpoint not present in enabled internal registry entries -# @TEST_INVARIANT deterministic_classification -> VERIFIED_BY: [policy_valid] class CleanPolicyEngine: def __init__( self, diff --git a/backend/src/services/clean_release/report_builder.py b/backend/src/services/clean_release/report_builder.py index 023a98a4a..0ced995c5 100644 --- a/backend/src/services/clean_release/report_builder.py +++ b/backend/src/services/clean_release/report_builder.py @@ -5,12 +5,6 @@ # @RELATION DEPENDS_ON -> [Models.CleanRelease.CleanReleaseModels] # @RELATION DEPENDS_ON -> [Services.Repository.RepositoryRelations] # @INVARIANT blocking_violations_count never exceeds violations_count. -# @TEST_CONTRACT ComplianceCheckRun,List[ComplianceViolation] -> ComplianceReport -# @TEST_FIXTURE blocked_with_two_violations -> file:backend/tests/fixtures/clean_release/fixtures_clean_release.json -# @TEST_EDGE empty_violations_for_blocked -> BLOCKED run with zero blocking violations raises ValueError -# @TEST_EDGE counter_mismatch -> blocking counter cannot exceed total violations counter -# @TEST_EDGE missing_operator_summary -> non-terminal run prevents report creation and summary generation -# @TEST_INVARIANT blocking_count_le_total_count -> VERIFIED_BY: [counter_mismatch, empty_violations_for_blocked] # @DATA_CONTRACT Input[ComplianceRun, List[ComplianceViolation]] -> Output[ComplianceReport] # @PRE Compliance run is terminal and repository persistence is available for report storage. # @POST Returns immutable report payloads with consistent violation counters and operator summary content. diff --git a/backend/src/services/dashboard_testing/automation/schedule.py b/backend/src/services/dashboard_testing/automation/schedule.py index 07ca09c67..6cbc6bc0b 100644 --- a/backend/src/services/dashboard_testing/automation/schedule.py +++ b/backend/src/services/dashboard_testing/automation/schedule.py @@ -40,7 +40,6 @@ def derive_coalesce(missed_execution_policy: str | None) -> bool: # @ingroup ScenarioAutomation # @BRIEF Validate an IANA timezone; DST-aware wall-clock cron keeps firing on local time. # @POST Returns the canonical timezone string; raises ValueError on unknown zone names. -# @TEST_EDGE unknown_zone -> ValueError; empty -> UTC default. def validate_timezone(timezone: str | None) -> str: tz = str(timezone or DEFAULT_TIMEZONE).strip() or DEFAULT_TIMEZONE try: diff --git a/backend/src/services/dashboard_testing/comparison.py b/backend/src/services/dashboard_testing/comparison.py index 1fad2e3a3..fc7dd8915 100644 --- a/backend/src/services/dashboard_testing/comparison.py +++ b/backend/src/services/dashboard_testing/comparison.py @@ -194,7 +194,7 @@ def _compare_row_set( # #endregion BaselineEngine.Comparison.CompareRowSet -# #region BaselineEngine.Comparison.Compare [C:5] [TYPE Function] +# #region BaselineEngine.Comparison.CompareValues [C:5] [TYPE Function] # @ingroup BaselineEngine # @BRIEF Apply comparison policy to actual vs expected normalized values. # @PRE Value kinds and policy are compatible. @@ -313,6 +313,6 @@ def compare_values( policy=policy, diff=diff, ) -# #endregion BaselineEngine.Comparison.Compare +# #endregion BaselineEngine.Comparison.CompareValues # #endregion BaselineEngine.Comparison.Compare diff --git a/backend/src/services/dashboard_testing/execution/approval.py b/backend/src/services/dashboard_testing/execution/approval.py index f077bd154..2d500e0e4 100644 --- a/backend/src/services/dashboard_testing/execution/approval.py +++ b/backend/src/services/dashboard_testing/execution/approval.py @@ -53,7 +53,6 @@ def create_prod_gate( # @PRE gate exists and is pending; run exists and is pending_approval. # @POST Gate status becomes approved|denied (write-once); run status transitions accordingly. # @SIDE_EFFECT DB writes on gate + run. -# @TEST_EDGE missing_gate -> ValueError; already_decided -> ValueError; approve -> queued; deny -> blocked. def decide_approval_gate(db: Session, gate_id: str, *, decision: str, actor_id: str, comment: str = "") -> ActionApprovalGate: if decision not in {"approve", "deny"}: raise ValueError("invalid gate decision") diff --git a/backend/src/services/dashboard_testing/execution/artifacts.py b/backend/src/services/dashboard_testing/execution/artifacts.py index 98c66d852..4c1dd8012 100644 --- a/backend/src/services/dashboard_testing/execution/artifacts.py +++ b/backend/src/services/dashboard_testing/execution/artifacts.py @@ -67,7 +67,6 @@ def has_verified_single_evidence(artifact_refs: list[str], step_outcome: dict[st # @BRIEF Persist one artifact row owned by a scenario run (or other generic owner). # @POST Returns the persisted ScenarioArtifact; validates owner_type/kind and sha256 shape. # @SIDE_EFFECT DB insert (artifact row). -# @TEST_EDGE invalid_owner_type -> ValueError; malformed_sha256 -> ValueError; unknown_run -> ValueError. def register_artifact( db: Session, *, diff --git a/backend/src/services/dashboard_testing/execution/comparison.py b/backend/src/services/dashboard_testing/execution/comparison.py index f04c651dc..f241318c3 100644 --- a/backend/src/services/dashboard_testing/execution/comparison.py +++ b/backend/src/services/dashboard_testing/execution/comparison.py @@ -29,7 +29,6 @@ def _step_map(steps: list[ScenarioStepRun]) -> dict[str, ScenarioStepRun]: # @ingroup ScenarioExecution # @BRIEF Compare run A vs run B: per-logical-step deltas + compatibility + revision warning. # @POST Returns {run_a, run_b, revision_warning, compatibility, step_deltas}; raises if either run missing. -# @TEST_EDGE missing_run -> ValueError; cross_revision -> revision_warning True; same_output -> unchanged. def compare_runs(db: Session, run_a_id: str, run_b_id: str) -> dict[str, Any]: run_a = db.query(ScenarioRun).filter(ScenarioRun.id == run_a_id).first() run_b = db.query(ScenarioRun).filter(ScenarioRun.id == run_b_id).first() diff --git a/backend/src/services/dashboard_testing/execution/lifecycle.py b/backend/src/services/dashboard_testing/execution/lifecycle.py index dfba48820..1a57d5be6 100644 --- a/backend/src/services/dashboard_testing/execution/lifecycle.py +++ b/backend/src/services/dashboard_testing/execution/lifecycle.py @@ -488,7 +488,6 @@ def pause_for_infrastructure(db: Session, run_id: str) -> tuple[ScenarioRun, str # @PRE run exists; token matches run.resume_token; run not terminal; no pending HumanCheckpoint. # @POST run.status -> queued, run.phase -> executing; token consumed (cleared) before walker continuation. # @SIDE_EFFECT DB write; never re-runs completed steps (dispatch resumes from ready frontier). -# @TEST_EDGE human_checkpoint_pending -> reject; stale_token -> reject; terminal -> reject. def resume_run(db: Session, run_id: str, *, resume_token: str, resume_reason: str) -> ScenarioRun: if resume_reason not in {"worker_recovered", "infrastructure_pause_resolved"}: raise ValueError("unsupported resume_reason") diff --git a/backend/src/services/dashboard_testing/execution/runner_plan.py b/backend/src/services/dashboard_testing/execution/runner_plan.py index 055df052d..af613cdad 100644 --- a/backend/src/services/dashboard_testing/execution/runner_plan.py +++ b/backend/src/services/dashboard_testing/execution/runner_plan.py @@ -58,7 +58,6 @@ def _topological_order(steps: list[dict[str, Any]], edges: list[dict[str, Any]]) # reject before a ScenarioRun/lease/adapter can exist. # @REJECTED Falling back from a missing action to a tool default was rejected because it bypasses # immutable ActionRegistry authority and fabricates retry safety. -# @TEST_EDGE missing_revision -> reject; revision_mismatch -> reject before run row creation. def derive_runner_plan(db: Session, scenario_id: str, revision_id: str) -> dict[str, Any]: revision = db.query(ScenarioRevision).filter( ScenarioRevision.scenario_id == scenario_id, diff --git a/backend/src/services/dashboard_testing/registry/create.py b/backend/src/services/dashboard_testing/registry/create.py index b7be1e77d..1d6553446 100644 --- a/backend/src/services/dashboard_testing/registry/create.py +++ b/backend/src/services/dashboard_testing/registry/create.py @@ -88,7 +88,6 @@ def register_consumed_save( # @BRIEF Validate a server-owned draft pack and stage entry + candidate revision atomically. # @PRE draft_pack_id identifies a valid scenario_pack artifact owned by the authenticated user. # @POST Returns ScenarioRegistryEntry and ScenarioRevision; no commit is performed by this service. -# @TEST_EDGE missing_runner_plan -> rejected; digest_mismatch -> rejected; owner_mismatch -> rejected. def create_scenario( db: Session, *, diff --git a/backend/src/services/dashboard_testing/registry/get.py b/backend/src/services/dashboard_testing/registry/get.py index af6cb0321..8fcc500f9 100644 --- a/backend/src/services/dashboard_testing/registry/get.py +++ b/backend/src/services/dashboard_testing/registry/get.py @@ -9,7 +9,6 @@ # @DATA_CONTRACT Input (db, scenario_id) -> Output {entry, graph, run_count, refresh_required} | None # @RELATION DEPENDS_ON -> [Models.ScenarioRegistry] # @RELATION DEPENDS_ON -> [ScenarioRegistry.Serializers] -# @TEST_EDGE not_found->404; stale detail->refresh banner flag. # @RATIONALE Lookup matches scenario_id OR scenario_key because 038/039 route identities are slugs # while the registry identity is a UUID; accepting both fixes the frontend getScenarioDraft 404 # without forcing a migration of existing draft references. diff --git a/backend/src/services/dashboard_testing/registry/lifecycle.py b/backend/src/services/dashboard_testing/registry/lifecycle.py index b531fd93c..419d60bc0 100644 --- a/backend/src/services/dashboard_testing/registry/lifecycle.py +++ b/backend/src/services/dashboard_testing/registry/lifecycle.py @@ -36,7 +36,6 @@ _TRANSITIONS: dict[str, set[str]] = { # @BRIEF Apply one valid state transition and append its audit record. # @PRE scenario exists; target state is allowed from current state. # @POST Entry state/metadata version updated and one audit row staged; no commit performed. -# @TEST_EDGE illegal_transition -> rejected; archive_with_runs -> allowed; restore -> DRAFT. def transition( db: Session, scenario_id: str, diff --git a/backend/src/services/dashboard_testing/registry/list.py b/backend/src/services/dashboard_testing/registry/list.py index e6c79d130..e40cf3ff2 100644 --- a/backend/src/services/dashboard_testing/registry/list.py +++ b/backend/src/services/dashboard_testing/registry/list.py @@ -8,8 +8,6 @@ # @DATA_CONTRACT Input (db, filters, page, page_size) -> Output {items: dict[], total: int} # @RELATION DEPENDS_ON -> [Models.ScenarioRegistry] # @RELATION DEPENDS_ON -> [ScenarioRegistry.Serializers] -# @TEST_EDGE empty->empty state; filter unknown dashboard->empty; LARGE->paged. -# @TEST_EDGE invalid page/page_size -> ValueError (route maps to 422). # @RATIONALE Tag filtering uses a substring match on the JSON array text (cast to VARCHAR, `%"tag"%`) # because JSON containment operators are not portable between PostgreSQL and the SQLite test # harness; tag values are slugs so quote-anchored matching is exact enough for MVP. diff --git a/backend/src/services/dashboard_testing/registry/revisions.py b/backend/src/services/dashboard_testing/registry/revisions.py index cdc904afa..714d453a9 100644 --- a/backend/src/services/dashboard_testing/registry/revisions.py +++ b/backend/src/services/dashboard_testing/registry/revisions.py @@ -35,7 +35,6 @@ def _content_hash(graph_snapshot: dict[str, Any]) -> str: # @BRIEF Append a validated graph as a candidate revision with an optional optimistic parent. # @PRE scenario exists; base_revision_id is absent or belongs to the same scenario. # @POST Returns a new candidate revision; current pointer and all prior rows remain unchanged. -# @TEST_EDGE stale_base -> ValueError; foreign_parent -> ValueError; immutable_prior -> prior row unchanged. def create_revision( db: Session, scenario_id: str, diff --git a/backend/src/services/dashboard_testing/registry/staleness.py b/backend/src/services/dashboard_testing/registry/staleness.py index 9f1f97e80..0e569395e 100644 --- a/backend/src/services/dashboard_testing/registry/staleness.py +++ b/backend/src/services/dashboard_testing/registry/staleness.py @@ -52,7 +52,6 @@ def _scenario_ids(db: Session, signal: dict[str, Any]) -> list[str]: # @BRIEF Apply StructureDiff/lineage signals to affected registry scenarios. # @PRE signals carry source_type, fingerprint, affected_ref and reason; source engine availability is explicit. # @POST Returns applied/skipped counts; rows and lifecycle changes are flushed but not committed. -# @TEST_EDGE chart_removed->BLOCKED; filter_scope_change->NEEDS_REVALIDATION; engine_down->skip. def apply_staleness(db: Session, signals: list[dict[str, Any]]) -> dict[str, Any]: applied: list[dict[str, str]] = [] skipped: list[dict[str, str]] = [] diff --git a/backend/src/services/dashboard_testing/scenario/capability_mapper.py b/backend/src/services/dashboard_testing/scenario/capability_mapper.py index d81721592..72d0f280e 100644 --- a/backend/src/services/dashboard_testing/scenario/capability_mapper.py +++ b/backend/src/services/dashboard_testing/scenario/capability_mapper.py @@ -8,8 +8,6 @@ # @RATIONALE Capability mapping keeps checklist intent reusable while allowing each dashboard to receive only safe, applicable step templates. # @REJECTED One-size-fits-all scripts and user-facing low-level tool selection — rejected because capabilities, safety, and available evidence vary per dashboard. # @INVARIANT No case is dropped and no tool is selected outside its registered capabilities. -# @TEST_EDGE xlsx_unavailable -> C04-C06 manual/unsupported with rationale. -# @TEST_EDGE technical_without_dataset_fields -> human checkpoint, no SQL. from __future__ import annotations @@ -63,8 +61,6 @@ def _has_all(capabilities: dict[str, bool], required: list[str]) -> tuple[bool, # @PRE Case is a known catalog case; capabilities is a boolean registry. # @POST Returns exactly one classification with rationale; no case is dropped. # @SIDE_EFFECT None. -# @TEST_EDGE xlsx_unavailable -> C04-C06 manual/unsupported with rationale. -# @TEST_EDGE technical_without_dataset_fields -> human checkpoint, no SQL. def map_case(case: dict[str, Any], capabilities: dict[str, bool], *, has_dataset_fields: bool) -> CapabilityMapping: """Classify a single checklist case for a dashboard given its capabilities.""" case_id: str = case["id"] diff --git a/backend/src/services/dashboard_testing/scenario/capture.py b/backend/src/services/dashboard_testing/scenario/capture.py index 3947ba9e5..5ea79d09e 100644 --- a/backend/src/services/dashboard_testing/scenario/capture.py +++ b/backend/src/services/dashboard_testing/scenario/capture.py @@ -11,8 +11,6 @@ # @REJECTED Registering a synthetic sha256 derived from run/step ids — the artifact digest MUST be the # actual captured image digest (T058); a placeholder hash proves nothing and breaks immutability. # @DATA_CONTRACT ScreenshotCaptureSpec + AgentRun -> DraftArtifactRef[] -# @TEST_EDGE capture_timeout -> step marked inconclusive; no artifact registered. -# @TEST_EDGE masking_applied -> two artifacts: original + masked; step output refs masked. from __future__ import annotations diff --git a/backend/src/services/dashboard_testing/scenario/checklist_catalog.py b/backend/src/services/dashboard_testing/scenario/checklist_catalog.py index 4915128c5..c0892a93e 100644 --- a/backend/src/services/dashboard_testing/scenario/checklist_catalog.py +++ b/backend/src/services/dashboard_testing/scenario/checklist_catalog.py @@ -9,7 +9,6 @@ # @INVARIANT Historic PDF outcomes are source notes, not expected values. # @RATIONALE Versioned declarative catalog keeps the 19 PDF cases reusable and auditable across dashboards. # @REJECTED Embedding the checklist as Python conditionals — mixed intent/data makes coverage unverifiable. -# @TEST_EDGE missing_case -> startup/catalog validation failure. from __future__ import annotations diff --git a/backend/src/services/dashboard_testing/scenario/compiler.py b/backend/src/services/dashboard_testing/scenario/compiler.py index 34f744746..a8fab48d6 100644 --- a/backend/src/services/dashboard_testing/scenario/compiler.py +++ b/backend/src/services/dashboard_testing/scenario/compiler.py @@ -7,9 +7,6 @@ # @SIDE_EFFECT Logging (REASON before compile; REFLECT with step count and hash after). # @DATA_CONTRACT CompileScenarioRequest -> DashboardTestScenario # @INVARIANT Steps consume only context/parameter/baseline/earlier-step refs. -# @TEST_INVARIANT Deterministic_Graph -> VERIFIED_BY: repeated_compile, shuffled_input_order. -# @TEST_EDGE missing_selector -> NEEDS_SELECTOR step and save blocker. -# @TEST_EDGE missing_baseline -> NEEDS_BASELINE; no embedded numeric truth. # @RATIONALE Rule/template compilation makes the agent a planner/explainer, not an executable-code generator. # @REJECTED LLM-generated ids/dependencies/code — non-deterministic and unsafe. diff --git a/backend/src/services/dashboard_testing/scenario/disposition.py b/backend/src/services/dashboard_testing/scenario/disposition.py index f8ce9cbf5..29eefadb7 100644 --- a/backend/src/services/dashboard_testing/scenario/disposition.py +++ b/backend/src/services/dashboard_testing/scenario/disposition.py @@ -8,8 +8,6 @@ # @RATIONALE Typed dispositions keep VLM review auditable while preserving graph immutability. # @REJECTED Free-text disposition with no audit record — unreviewable and non-reproducible. # @DATA_CONTRACT HumanDispositionRequest -> ScenarioStep (updated) -# @TEST_EDGE double_disposition -> 409. -# @TEST_EDGE disposition_blank -> accepted for dismiss/inconclusive; confirm requires non-blank comment. # @INVARIANT Disposition never alters the scenario graph structure or step ordering. from __future__ import annotations diff --git a/backend/src/services/dashboard_testing/scenario/pack_compiler.py b/backend/src/services/dashboard_testing/scenario/pack_compiler.py index 0245ac508..8bdd69513 100644 --- a/backend/src/services/dashboard_testing/scenario/pack_compiler.py +++ b/backend/src/services/dashboard_testing/scenario/pack_compiler.py @@ -7,9 +7,6 @@ # @SIDE_EFFECT Logging (REASON before generation; REFLECT with status after). # @DATA_CONTRACT DashboardTestScenario + ValidationResult -> DraftPack # @INVARIANT Errors/unresolved required inputs make pack preview_only; direct code/path input is impossible. -# @TEST_INVARIANT No_LLM_To_Code -> VERIFIED_BY: injected_code_field, template_registry_only. -# @TEST_EDGE unknown_template -> blocked. -# @TEST_EDGE path_traversal -> blocked before artifact registration. # @RATIONALE Versioned templates make generated behavior reviewable and reproducible. # @REJECTED Generate arbitrary Playwright/Python code then scan it — scanners cannot prove semantic safety. diff --git a/backend/src/services/dashboard_testing/scenario/pack_registry.py b/backend/src/services/dashboard_testing/scenario/pack_registry.py index d1c57c987..ee5ed0ace 100644 --- a/backend/src/services/dashboard_testing/scenario/pack_registry.py +++ b/backend/src/services/dashboard_testing/scenario/pack_registry.py @@ -7,8 +7,6 @@ # @RATIONALE Drafts live in 036's artifact registry — outside the target repository — so pack bytes # stay reproducible and reviewable without touching production code. # @REJECTED Writing pack files directly into the target repo — bypasses 036 approval and artifact lifecycle. -# @TEST_EDGE terminal_run -> ValueError; unsafe intended_path -> ValueError. -# @TEST_EDGE same_sha_replay -> idempotent; changed_sha_conflict -> ValueError. from __future__ import annotations import hashlib diff --git a/backend/src/services/dashboard_testing/scenario/validator.py b/backend/src/services/dashboard_testing/scenario/validator.py index d22b782d5..851ce766e 100644 --- a/backend/src/services/dashboard_testing/scenario/validator.py +++ b/backend/src/services/dashboard_testing/scenario/validator.py @@ -9,10 +9,6 @@ # @RATIONALE Validation is a hard safety boundary between agent-produced intent and artifact generation; deterministic findings give the user a recoverable explanation instead of a runtime surprise. # @REJECTED Silent graph repair or best-effort artifact generation — rejected because auto-fixing refs, cycles, or unsafe actions can change business intent without review. # @INVARIANT Cycles, missing/duplicate refs, unregistered tools, SQL, raw metric truth, and path traversal block compilation. -# @TEST_EDGE cycle -> error contains cycle path. -# @TEST_EDGE duplicate_output -> both producer ids reported. -# @TEST_EDGE raw_metric_expected -> forbidden baseline literal error. -# @TEST_EDGE unreachable_step -> warning/error according to required coverage. from __future__ import annotations diff --git a/backend/src/services/dashboard_testing/scenario/vlm.py b/backend/src/services/dashboard_testing/scenario/vlm.py index 98dda3f3a..74bafec99 100644 --- a/backend/src/services/dashboard_testing/scenario/vlm.py +++ b/backend/src/services/dashboard_testing/scenario/vlm.py @@ -7,9 +7,6 @@ # @SIDE_EFFECT Logging (REASON before submission; REFLECT with finding count after). # @INVARIANT VLM findings are advisory observations, not deterministic assertions; they must not alter metric baseline truth. # @INVARIANT Prompt template version and hash are recorded per analysis; stale prompts block analysis. -# @TEST_EDGE stale_prompt -> 422 with STALE_PROMPT code. -# @TEST_EDGE vlm_timeout -> step inconclusive; existing findings retained. -# @TEST_EDGE empty_response -> findings array empty; step status inconclusive with reason. # @RATIONALE Typed findings make VLM output auditable and reviewable; raw text would require parsing and is not reproducible. # @REJECTED Embedding VLM findings directly as assertion results — they are observations for human review, not deterministic pass/fail. diff --git a/backend/src/services/dashboard_testing/structure_snapshot_capture.py b/backend/src/services/dashboard_testing/structure_snapshot_capture.py index c686b3396..ef2e5b411 100644 --- a/backend/src/services/dashboard_testing/structure_snapshot_capture.py +++ b/backend/src/services/dashboard_testing/structure_snapshot_capture.py @@ -1,4 +1,4 @@ -#region BaselineEngine.StructureSnapshot.Capture [C:5] [TYPE Module] [SEMANTICS baseline,structure-snapshot,capture,release-bound] +# #region BaselineEngine.StructureSnapshot.Capture [C:5] [TYPE Module] [SEMANTICS baseline,structure-snapshot,capture,release-bound] # @defgroup BaselineEngine Release-bound snapshot capture with full identity validation. # @LAYER Service # @RELATION DEPENDS_ON -> [DashboardTesting.Schemas.StructureSnapshot] @@ -280,4 +280,4 @@ async def capture_release_snapshot( query_model_fingerprint=model.query_model_fingerprint, warnings=len(model.warnings), ) # #endregion BaselineEngine.StructureSnapshot.CaptureFunction -#endregion BaselineEngine.StructureSnapshot.Capture +# #endregion BaselineEngine.StructureSnapshot.Capture diff --git a/backend/src/services/dashboard_testing/structure_snapshot_diff.py b/backend/src/services/dashboard_testing/structure_snapshot_diff.py index 0cdbdde7e..77fb626ff 100644 --- a/backend/src/services/dashboard_testing/structure_snapshot_diff.py +++ b/backend/src/services/dashboard_testing/structure_snapshot_diff.py @@ -1,4 +1,4 @@ -#region BaselineEngine.StructureSnapshot.Diff [C:4] [TYPE Module] [SEMANTICS baseline,structure-snapshot,diff,metadata-verification,release-bound] +# #region BaselineEngine.StructureSnapshot.Diff [C:4] [TYPE Module] [SEMANTICS baseline,structure-snapshot,diff,metadata-verification,release-bound] # @defgroup BaselineEngine Release-bound snapshot diff with full metadata cross-verification. # @LAYER Service # @RELATION DEPENDS_ON -> [DashboardTesting.Schemas.StructureSnapshot] @@ -284,4 +284,4 @@ def diff_release_snapshots( return diff # #endregion BaselineEngine.StructureSnapshot.DiffFunction -#endregion BaselineEngine.StructureSnapshot.Diff +# #endregion BaselineEngine.StructureSnapshot.Diff diff --git a/backend/src/services/dashboard_testing/visual_baseline.py b/backend/src/services/dashboard_testing/visual_baseline.py index 3129f13e6..8877a534f 100644 --- a/backend/src/services/dashboard_testing/visual_baseline.py +++ b/backend/src/services/dashboard_testing/visual_baseline.py @@ -39,8 +39,6 @@ from src.services.dashboard_testing.visual_ssim import ( # Any change to tab structure, chart ordering, or geometry produces a different fingerprint. # Tab children are listed in their insertion order (preserving Superset's order), # not alphabetically — reordering tabs produces a different fingerprint. -# @TEST_EDGE tab_reorder -> different fingerprint even if chart positions unchanged. -# @TEST_EDGE region_nesting -> tab parentage included in canonical JSON. # @RATIONALE Preserving tab child ordering (not sorting alphabetically) means a dashboard # where tabs are reordered produces a different layout fingerprint, which is # critical for staleness detection — reordered tabs mean the baseline screenshot @@ -225,7 +223,7 @@ def _check_visual_immutability( # #endregion BaselineEngine.Visual.CheckVisualImmutability -# #region BaselineEngine.Visual.Compare [C:4] [TYPE Function] +# #region BaselineEngine.Visual.CompareVisualBaseline [C:4] [TYPE Function] # @ingroup BaselineEngine # @BRIEF Compare visual baseline entry against actual screenshot evidence. # @PRE visual_baseline is a VisualBaselineEntry with valid fingerprints and policy. @@ -388,6 +386,6 @@ def compare_visual_baseline( stale_dimensions=stale_dimensions, warnings=warnings, ) -# #endregion BaselineEngine.Visual.Compare +# #endregion BaselineEngine.Visual.CompareVisualBaseline # #endregion BaselineEngine.Visual.Compare diff --git a/backend/src/services/git/_base.py b/backend/src/services/git/_base.py index 6e1abaeb6..f438fa04a 100644 --- a/backend/src/services/git/_base.py +++ b/backend/src/services/git/_base.py @@ -32,7 +32,7 @@ from src.models.config import AppConfigRecord from src.models.git import GitRepository -# #region Services.Base.GitServiceBase [C:4] [TYPE Class] +# #region Services.Base.GitServiceBase.Class [C:4] [TYPE Class] # @defgroup Services Module group. # @BRIEF Base class for GitService providing initialization, path resolution, repository lifecycle, identity, concurrent locking, and shared HTTP client. # @PRE base_path is a valid string path. @@ -400,5 +400,5 @@ class GitServiceBase: self._closed = True await self._http_client.aclose() # endregion Services.Base.Close -# #endregion Services.Base.GitServiceBase +# #endregion Services.Base.GitServiceBase.Class # #endregion Services.Base.GitServiceBase diff --git a/backend/src/services/git/_branch.py b/backend/src/services/git/_branch.py index 42f6498a3..ef845bccb 100644 --- a/backend/src/services/git/_branch.py +++ b/backend/src/services/git/_branch.py @@ -26,7 +26,7 @@ _HOTFIX_PREFIX = "hotfix/" _BUGFIX_PREFIX = "bugfix/" -# #region Services.Branch.GitServiceBranchMixin [C:3] [TYPE Class] +# #region Services.Branch.GitServiceBranchMixin.Class [C:3] [TYPE Class] # @defgroup Services Module group. # @BRIEF Mixin providing branch and commit operations for GitService. class GitServiceBranchMixin: @@ -521,5 +521,5 @@ class GitServiceBranchMixin: }, ] # endregion Services.Branch.GetBranchProtectionRules -# #endregion Services.Branch.GitServiceBranchMixin +# #endregion Services.Branch.GitServiceBranchMixin.Class # #endregion Services.Branch.GitServiceBranchMixin diff --git a/backend/src/services/git/_gitea.py b/backend/src/services/git/_gitea.py index b75b291a6..221ce8b98 100644 --- a/backend/src/services/git/_gitea.py +++ b/backend/src/services/git/_gitea.py @@ -13,7 +13,7 @@ from src.core.logger import belief_scope, logger from src.models.git import GitProvider -# #region Services.Gitea.GitServiceGiteaMixin [C:3] [TYPE Class] +# #region Services.Gitea.GitServiceGiteaMixin.Class [C:3] [TYPE Class] # @defgroup Services Module group. # @BRIEF Mixin providing Gitea API operations for GitService. class GitServiceGiteaMixin: @@ -268,5 +268,5 @@ class GitServiceGiteaMixin: "status": data.get("state") or "open", } # endregion Services.Gitea.CreateGiteaPullRequest -# #endregion Services.Gitea.GitServiceGiteaMixin +# #endregion Services.Gitea.GitServiceGiteaMixin.Class # #endregion Services.Gitea.GitServiceGiteaMixin diff --git a/backend/src/services/git/_merge.py b/backend/src/services/git/_merge.py index 805e1a4f3..b733ffe10 100644 --- a/backend/src/services/git/_merge.py +++ b/backend/src/services/git/_merge.py @@ -16,7 +16,7 @@ from git.objects.blob import Blob from src.core.logger import belief_scope, logger -# #region Services.Merge.GitServiceMergeMixin [C:3] [TYPE Class] +# #region Services.Merge.GitServiceMergeMixin.Class [C:3] [TYPE Class] # @defgroup Services Module group. # @BRIEF Mixin providing merge operations for GitService. class GitServiceMergeMixin: @@ -464,5 +464,5 @@ class GitServiceMergeMixin: "source_deleted": source_deleted, } # endregion Services.Merge.MergeBranch -# #endregion Services.Merge.GitServiceMergeMixin +# #endregion Services.Merge.GitServiceMergeMixin.Class # #endregion Services.Merge.GitServiceMergeMixin diff --git a/backend/src/services/git/_sync.py b/backend/src/services/git/_sync.py index d1489f0c4..47ed54f0c 100644 --- a/backend/src/services/git/_sync.py +++ b/backend/src/services/git/_sync.py @@ -28,7 +28,7 @@ def _http_host(url_value: str | None) -> str | None: return f"{parsed.hostname.lower()}:{parsed.port}" if parsed.port else parsed.hostname.lower() -# #region Services.Sync.GitServiceSyncMixin [C:3] [TYPE Class] +# #region Services.Sync.GitServiceSyncMixin.Class [C:3] [TYPE Class] # @defgroup Services Module group. # @BRIEF Mixin providing push and pull operations with safe repository-binding checks. class GitServiceSyncMixin: @@ -251,5 +251,5 @@ class GitServiceSyncMixin: except Exception: pass # endregion Services.Sync.PullChanges -# #endregion Services.Sync.GitServiceSyncMixin +# #endregion Services.Sync.GitServiceSyncMixin.Class # #endregion Services.Sync.GitServiceSyncMixin diff --git a/backend/src/services/git/_url.py b/backend/src/services/git/_url.py index 891010f6f..da6ff9b70 100644 --- a/backend/src/services/git/_url.py +++ b/backend/src/services/git/_url.py @@ -15,7 +15,7 @@ from src.core.logger import logger from src.models.git import GitRepository -# #region Services.Url.GitServiceUrlMixin [C:3] [TYPE Class] +# #region Services.Url.GitServiceUrlMixin.Class [C:3] [TYPE Class] # @defgroup Services Module group. # @BRIEF URL helper methods for GitService — extract host, strip credentials, replace host, align origin, parse remote identity, derive server URL, normalize server URL. class GitServiceUrlMixin: @@ -205,5 +205,5 @@ class GitServiceUrlMixin: raise HTTPException(status_code=400, detail="Git server URL is required") return normalized.rstrip("/") # endregion Services.Url.NormalizeGitServerUrl -# #endregion Services.Url.GitServiceUrlMixin +# #endregion Services.Url.GitServiceUrlMixin.Class # #endregion Services.Url.GitServiceUrlMixin diff --git a/backend/src/services/lineage/deprecation.py b/backend/src/services/lineage/deprecation.py index 92722a1e2..d8014df32 100644 --- a/backend/src/services/lineage/deprecation.py +++ b/backend/src/services/lineage/deprecation.py @@ -160,7 +160,6 @@ def recompute_escalations(db: Session, environment_id: str, now: datetime | None # @ingroup Lineage # @BRIEF Gate an operation against a deprecated dataset; raise typed DatasetDeprecatedError on expiry. # @POST Returns True when the dataset is safe to use; raises DatasetDeprecatedError when expired_blocked. -# @TEST_EDGE expired state always raises naming the successor — never a raw 404 (SC-006). def assert_not_blocked(record: DatasetDeprecation | None, now: datetime | None = None) -> bool: if record is None: return True diff --git a/backend/src/services/load_testing/bounded_result.py b/backend/src/services/load_testing/bounded_result.py index 0e0f16088..71db45146 100644 --- a/backend/src/services/load_testing/bounded_result.py +++ b/backend/src/services/load_testing/bounded_result.py @@ -3,7 +3,6 @@ # @BRIEF Bounded load-path result processing (LOAD-FR-018): raw digest, row count, first-N sample ≤100/256KiB. # @LAYER Service # @INVARIANT Full 037 normalization stays in the verification path; the load path never invokes it. -# @TEST_EDGE >10% GIL distortion requires escalation, not silent ProcessPool introduction. from __future__ import annotations import hashlib diff --git a/backend/src/services/load_testing/capacity.py b/backend/src/services/load_testing/capacity.py index c319d014d..156332bd9 100644 --- a/backend/src/services/load_testing/capacity.py +++ b/backend/src/services/load_testing/capacity.py @@ -32,7 +32,6 @@ class CapacityResult: # @ingroup LoadTesting # @BRIEF Resolve the effective cap honoring stage default, per-env override, requested value, ceiling, and reserved slots. # @DATA_CONTRACT EnvironmentPolicy + RequestedConcurrency -> CapacityResult -# @TEST_EDGE pool_size=5 -> rejected (no safe load slot, reserve=5). def resolve_capacity( *, stage: str, diff --git a/backend/src/services/load_testing/profile.py b/backend/src/services/load_testing/profile.py index 386f4526f..9bc9b04af 100644 --- a/backend/src/services/load_testing/profile.py +++ b/backend/src/services/load_testing/profile.py @@ -66,7 +66,6 @@ def _check_filters(axes: dict, filter_metadata: dict | None) -> list[str]: # @ingroup LoadTesting # @BRIEF Validate profile axes, execution mode, filter values, circuit-breaker bounds, and forbidden fields. # @DATA_CONTRACT ProfileInput + FilterMetadata -> ValidationResult -# @TEST_EDGE unknown filter value -> NEEDS_CONTEXT marker (never invented). def validate_profile(*, axes: dict, execution_mode: str, filter_metadata: dict | None = None, **payload) -> ValidationResult: errors = _check_forbidden(payload) + _check_mode(execution_mode, payload) unknown_axes = set(axes) - set(CLOSED_AXES) diff --git a/backend/src/services/profile_service.py b/backend/src/services/profile_service.py index ffcf0f032..0991f521b 100644 --- a/backend/src/services/profile_service.py +++ b/backend/src/services/profile_service.py @@ -17,12 +17,6 @@ # # @INVARIANT Profile ID needs to be unique per-user session # -# @TEST_CONTRACT ProfilePreferenceUpdateRequest -> ProfilePreferenceResponse -# @TEST_FIXTURE valid_profile_update -> {"user_id":"u-1","superset_username":"John_Doe","show_only_my_dashboards":true} -# @TEST_EDGE enable_without_username -> toggle=true with empty username returns validation error -# @TEST_EDGE cross_user_mutation -> attempt to update another user preference returns forbidden -# @TEST_EDGE lookup_env_not_found -> unknown environment_id returns not found -# @TEST_INVARIANT normalization_consistency -> VERIFIED_BY: [valid_profile_update, enable_without_username] # @DATA_CONTRACT Profile_id -> ProfileInfo; session_id -> valid UUID # @PRE Session is active and valid # @POST Profile with updated fields populated and diff --git a/backend/src/services/reports/normalizer.py b/backend/src/services/reports/normalizer.py index 80e16b61c..cbeb05124 100644 --- a/backend/src/services/reports/normalizer.py +++ b/backend/src/services/reports/normalizer.py @@ -111,20 +111,6 @@ def extract_error_context(task: Task, report_status: ReportStatus) -> ErrorConte # @POST When include_result=False (list projection), omits details.result so list # payloads stay small; detail endpoint keeps include_result=True. # -# @TEST_CONTRACT NormalizeTaskReport -> -# { -# required_fields: {task: Task}, -# invariants: [ -# "Returns a valid TaskReport object", -# "Maps TaskStatus to ReportStatus deterministically", -# "Extracts ErrorContext for FAILED/PARTIAL tasks", -# "List projection (include_result=False) never embeds task.result" -# ] -# } -# @TEST_FIXTURE valid_task -> {"task": "MockTask(id='1', plugin_id='superset-migration', status=TaskStatus.SUCCESS)"} -# @TEST_EDGE task_with_error -> {"task": "MockTask(status=TaskStatus.FAILED, logs=[LogEntry(level='ERROR', message='Failed')])"} -# @TEST_EDGE unknown_plugin_type -> {"task": "MockTask(plugin_id='unknown-plugin', status=TaskStatus.PENDING)"} -# @TEST_INVARIANT deterministic_normalization -> verifies: [valid_task, task_with_error, unknown_plugin_type] def normalize_task_report(task: Task, *, include_result: bool = True) -> TaskReport: with belief_scope("normalize_task_report"): task_type = resolve_task_type(task.plugin_id) diff --git a/backend/src/services/reports/report_service.py b/backend/src/services/reports/report_service.py index a2525852e..275782cb3 100644 --- a/backend/src/services/reports/report_service.py +++ b/backend/src/services/reports/report_service.py @@ -96,18 +96,6 @@ def _filter_tasks_by_rbac(tasks: list[Task], current_user) -> list[Task]: # dedicated reporting database was rejected — the operational task store is the SSOT and # syncing to a reporting DB would add latency and consistency risk. # -# @TEST_CONTRACT ReportsServiceModel -> -# { -# required_fields: {task_manager: TaskManager}, -# invariants: [ -# "list_reports returns a matching ReportCollection", -# "get_report_detail returns a valid ReportDetailView or None" -# ] -# } -# @TEST_FIXTURE valid_service -> {"task_manager": "MockTaskManager()"} -# @TEST_EDGE empty_task_list -> returns empty ReportCollection -# @TEST_EDGE report_not_found -> get_report_detail returns None -# @TEST_INVARIANT consistent_pagination -> verifies: [valid_service] class ReportsService: # region Services.ReportService.Init [TYPE Function] # @BRIEF: Initialize service with TaskManager dependency. diff --git a/backend/src/services/reports/type_profiles.py b/backend/src/services/reports/type_profiles.py index 2de217170..1460d4bd3 100644 --- a/backend/src/services/reports/type_profiles.py +++ b/backend/src/services/reports/type_profiles.py @@ -76,16 +76,6 @@ TASK_TYPE_PROFILES: dict[TaskType, dict[str, Any]] = { # @PRE plugin_id may be None or unknown. # @POST Always returns one of TaskType enum values. # -# @TEST_CONTRACT ResolveTaskType -> -# { -# required_fields: {plugin_id: str}, -# invariants: ["returns TaskType.UNKNOWN for missing/unmapped plugin_id"] -# } -# @TEST_FIXTURE valid_plugin -> {"plugin_id": "superset-migration"} -# @TEST_EDGE empty_plugin -> {"plugin_id": ""} -# @TEST_EDGE none_plugin -> {"plugin_id": None} -# @TEST_EDGE unknown_plugin -> {"plugin_id": "invalid-plugin"} -# @TEST_INVARIANT fallback_to_unknown -> verifies: [empty_plugin, none_plugin, unknown_plugin] def resolve_task_type(plugin_id: str | None) -> TaskType: with belief_scope("resolve_task_type"): normalized = (plugin_id or "").strip() @@ -103,14 +93,6 @@ def resolve_task_type(plugin_id: str | None) -> TaskType: # @PRE task_type may be known or unknown. # @POST Returns a profile dict and never raises for unknown types. # -# @TEST_CONTRACT GetTypeProfile -> -# { -# required_fields: {task_type: TaskType}, -# invariants: ["returns a valid metadata dictionary even for UNKNOWN"] -# } -# @TEST_FIXTURE valid_profile -> {"task_type": "migration"} -# @TEST_EDGE missing_profile -> {"task_type": "some_new_type"} -# @TEST_INVARIANT always_returns_dict -> verifies: [valid_profile, missing_profile] def get_type_profile(task_type: TaskType) -> dict[str, Any]: with belief_scope("get_type_profile"): return TASK_TYPE_PROFILES.get(task_type, TASK_TYPE_PROFILES[TaskType.UNKNOWN]) diff --git a/backend/tests/plugins/test_llm_analysis_service.py b/backend/tests/plugins/test_llm_analysis_service.py index c36450735..3e6539861 100644 --- a/backend/tests/plugins/test_llm_analysis_service.py +++ b/backend/tests/plugins/test_llm_analysis_service.py @@ -712,8 +712,8 @@ class TestLLMClientInit: with patch.dict(os.environ, {"OPENROUTER_SITE_URL": "https://example.com", "OPENROUTER_APP_NAME": "TestApp"}): from src.plugins.llm_analysis.service import LLMClient - with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"): - with patch("src.plugins.llm_analysis.service.AsyncOpenAI"): + with patch("src.plugins.llm_analysis._llm_client_core.httpx.AsyncClient"): + with patch("src.plugins.llm_analysis._llm_client_core.AsyncOpenAI"): client = LLMClient( provider_type=LLMProviderType.OPENROUTER, api_key="sk-test", @@ -725,8 +725,8 @@ class TestLLMClientInit: def test_init_kilo_headers(self): from src.plugins.llm_analysis.service import LLMClient - with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"): - with patch("src.plugins.llm_analysis.service.AsyncOpenAI"): + with patch("src.plugins.llm_analysis._llm_client_core.httpx.AsyncClient"): + with patch("src.plugins.llm_analysis._llm_client_core.AsyncOpenAI"): client = LLMClient( provider_type=LLMProviderType.KILO, api_key="sk-test", @@ -738,8 +738,8 @@ class TestLLMClientInit: def _make_client(self, api_key="sk-test"): from src.plugins.llm_analysis.service import LLMClient - with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"): - with patch("src.plugins.llm_analysis.service.AsyncOpenAI"): + with patch("src.plugins.llm_analysis._llm_client_core.httpx.AsyncClient"): + with patch("src.plugins.llm_analysis._llm_client_core.AsyncOpenAI"): return LLMClient( provider_type=LLMProviderType.OPENAI, api_key=api_key, @@ -974,8 +974,8 @@ class TestLLMClientAnalyze: async def test_analyze_dashboard_delegates_to_multimodal(self): from src.plugins.llm_analysis.service import LLMClient as RealClient - with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"): - with patch("src.plugins.llm_analysis.service.AsyncOpenAI"): + with patch("src.plugins.llm_analysis._llm_client_core.httpx.AsyncClient"): + with patch("src.plugins.llm_analysis._llm_client_core.AsyncOpenAI"): real_client = RealClient( provider_type=LLMProviderType.OPENAI, api_key="sk-test", @@ -1002,8 +1002,8 @@ class TestLLMClientOptimizeImages: Image.new("RGB", (100, 100), (255, 0, 0)).save(png_path, "PNG") # Create a real client with mocked internals - with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"): - with patch("src.plugins.llm_analysis.service.AsyncOpenAI"): + with patch("src.plugins.llm_analysis._llm_client_core.httpx.AsyncClient"): + with patch("src.plugins.llm_analysis._llm_client_core.AsyncOpenAI"): real_client = LLMClient( provider_type=LLMProviderType.OPENAI, api_key="sk-test", @@ -1101,8 +1101,8 @@ class TestLLMClientFetchModels: async def test_fetch_success(self): from src.plugins.llm_analysis.service import LLMClient - with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"): - with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI: + with patch("src.plugins.llm_analysis._llm_client_core.httpx.AsyncClient"): + with patch("src.plugins.llm_analysis._llm_client_core.AsyncOpenAI") as MockOpenAI: mock_client = MagicMock() MockOpenAI.return_value = mock_client mock_response = MagicMock() @@ -1127,8 +1127,8 @@ class TestLLMClientFetchModels: async def test_fetch_failure_raises(self): from src.plugins.llm_analysis.service import LLMClient - with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"): - with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI: + with patch("src.plugins.llm_analysis._llm_client_core.httpx.AsyncClient"): + with patch("src.plugins.llm_analysis._llm_client_core.AsyncOpenAI") as MockOpenAI: mock_client = MagicMock() MockOpenAI.return_value = mock_client mock_client.models.list = AsyncMock(side_effect=ConnectionError("API unavailable")) @@ -1151,8 +1151,8 @@ class TestLLMClientTestRuntimeConnection: async def test_runtime_connection_success(self): from src.plugins.llm_analysis.service import LLMClient - with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"): - with patch("src.plugins.llm_analysis.service.AsyncOpenAI"): + with patch("src.plugins.llm_analysis._llm_client_core.httpx.AsyncClient"): + with patch("src.plugins.llm_analysis._llm_client_core.AsyncOpenAI"): real_client = LLMClient( provider_type=LLMProviderType.OPENAI, api_key="sk-test", @@ -1182,8 +1182,8 @@ class TestGetJsonCompletion: """Edge: JSON embedded in ```json code block parsed.""" from src.plugins.llm_analysis.service import LLMClient - with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"): - with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI: + with patch("src.plugins.llm_analysis._llm_client_core.httpx.AsyncClient"): + with patch("src.plugins.llm_analysis._llm_client_core.AsyncOpenAI") as MockOpenAI: mock_client = MagicMock() MockOpenAI.return_value = mock_client @@ -1209,8 +1209,8 @@ class TestGetJsonCompletion: """Edge: JSON in ``` code block (no json marker).""" from src.plugins.llm_analysis.service import LLMClient - with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"): - with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI: + with patch("src.plugins.llm_analysis._llm_client_core.httpx.AsyncClient"): + with patch("src.plugins.llm_analysis._llm_client_core.AsyncOpenAI") as MockOpenAI: mock_client = MagicMock() MockOpenAI.return_value = mock_client @@ -1235,8 +1235,8 @@ class TestGetJsonCompletion: """Negative: null content raises RuntimeError.""" from src.plugins.llm_analysis.service import LLMClient - with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"): - with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI: + with patch("src.plugins.llm_analysis._llm_client_core.httpx.AsyncClient"): + with patch("src.plugins.llm_analysis._llm_client_core.AsyncOpenAI") as MockOpenAI: mock_client = MagicMock() MockOpenAI.return_value = mock_client @@ -1261,8 +1261,8 @@ class TestGetJsonCompletion: """Negative: empty choices raises RuntimeError.""" from src.plugins.llm_analysis.service import LLMClient - with patch("src.plugins.llm_analysis.service.httpx.AsyncClient"): - with patch("src.plugins.llm_analysis.service.AsyncOpenAI") as MockOpenAI: + with patch("src.plugins.llm_analysis._llm_client_core.httpx.AsyncClient"): + with patch("src.plugins.llm_analysis._llm_client_core.AsyncOpenAI") as MockOpenAI: mock_client = MagicMock() MockOpenAI.return_value = mock_client mock_response = MagicMock() diff --git a/docs/adr/ADR-0002-semantic-protocol.md b/docs/adr/ADR-0002-semantic-protocol.md index 0f118d86d..b583a5308 100644 --- a/docs/adr/ADR-0002-semantic-protocol.md +++ b/docs/adr/ADR-0002-semantic-protocol.md @@ -9,6 +9,7 @@ # @REJECTED Decorator‑based contracts (`@contract`, `@pre`, `@post`) — rejected because they are Python‑only, cannot annotate Svelte components or TypeScript, and break the unified semantic graph spanning both platforms. # @REJECTED JSDoc/TSDoc for the frontend — rejected because it would create a second annotation language, fragmenting the semantic graph into two incompatible halves and forcing agents to master two different contract systems. # @REJECTED Embedding protocol rules directly in ADRs (the previous version of this document) — rejected because it duplicates the skill content and inevitably diverges. Agents receiving both the skill and the ADR would face conflicting versions; the skill is the single source of truth. +@REJECTED Treating typical C4/C5 tags as required, and filling `@RATIONALE`/`@PRE`/`@BRIEF` to silence audits — rejected because synthetic markup poisons navigation more than a bare anchor (INV_9). ## Decision @@ -24,14 +25,22 @@ skills in `.agents/skills/` (mirrored for compatible OpenCode workflows under | `semantics-svelte` | `.agents/skills/semantics-svelte/SKILL.md` | Svelte 5 UX state and component conventions | | `semantics-testing` | `.agents/skills/semantics-testing/SKILL.md` | Test constraints and invariant traceability | -**Key principle:** Skills are the protocol. This ADR is the adoption record. When an agent needs to know *what tags are required at C4*, it reads `semantics-core`. When it needs to know *why this project chose C4 annotations at all*, it reads this ADR. +**Key principle:** Skills are the protocol. This ADR is the adoption record. `.axiom/axiom_config.yaml` is a **projection** of `semantics-core` (index paths, tag catalog) and MUST NOT introduce required-tag gates that the SSOT marks as advisory. + +**Runtimes:** Axiom MCP (`search` / `audit`) when those tools are connected (OpenCode). Grok TUI and any session without Axiom uses zombie-mode from `semantics-core` §VIII (grep on `[SEMANTICS`, `#region` pairs). Both are first-class. + +**INV_9:** a missing `@`-tag is valid; a synthetic tag is a defect. Decision memory and every other metadata tag are written only when a local fact exists. + +**Doxygen navigation:** generated HTML/maps are a two-level agent graph — modules (`root.map`, `\defgroup`) then functions (module map `@FUNCTIONS`, `\ingroup`). `make docs-nav`. Not a flat `axiom_*.html` dump. + +When an agent needs to know *what tags are typical at C4*, it reads `semantics-core`. Nothing is required by tier. When it needs to know *why this project chose GRACE*, it reads this ADR. ## Enforcement Agent commands and reviews load the relevant skills before changing a contracted module. Feature specs reference this ADR and the relevant skill when they introduce C4/C5 -contracts. Code review preserves declared `@RATIONALE` and `@REJECTED` decisions -unless a successor ADR explicitly changes them. This ADR deliberately does not -claim a verifier that is absent from the repository. +contracts. Code review preserves authentic `@RATIONALE` and `@REJECTED` decisions +unless a successor ADR explicitly changes them. Synthetic tags are removed, not +rewritten. This ADR does not treat missing-tag audits as a merge gate. # [/DEF:Doc.Adr.ADR0002:ADR] diff --git a/docs/api/Doxyfile b/docs/api/Doxyfile index 4064d1a7b..aced8d846 100644 --- a/docs/api/Doxyfile +++ b/docs/api/Doxyfile @@ -1,7 +1,11 @@ -# superset-tools Doxygen configuration +# superset-tools Doxygen configuration (source-comment XML/HTML extract). +# +# Agent navigation graph (modules → functions) is NOT this file. +# Use `make docs-nav` / `doc-gen --nav --html` — see semantics-core §IX. # # Run from repository root: -# make docs-doxygen # clean XML+HTML build +# make docs-nav # fractal module→function graph for agents +# make docs-doxygen # clean XML+HTML build from source comments # make docs-doxygen-check # validate config + build presence # # All relative paths below are resolved against the directory from which diff --git a/frontend/src/lib/api/translate/datasources.ts b/frontend/src/lib/api/translate/datasources.ts index d158b64c2..5290b26fc 100644 --- a/frontend/src/lib/api/translate/datasources.ts +++ b/frontend/src/lib/api/translate/datasources.ts @@ -49,7 +49,6 @@ export async function fetchDatasourceColumns(datasourceId: string, // #region Api.Datasources.FetchPreview [C:2] [TYPE Function] [SEMANTICS translate, preview, rows] // @BRIEF Fetch a sample preview of rows for a translation job. -// @PRE jobId is a non-empty string. // @POST Returns preview data with sample rows. // @RELATION DEPENDS_ON -> [Api.ApiModule.PostApi] export async function fetchPreview(jobId: string, sampleSize: number = 10, envId: string = ''): Promise { diff --git a/frontend/src/lib/api/translate/jobs.ts b/frontend/src/lib/api/translate/jobs.ts index 8ca409e2c..6c245e76d 100644 --- a/frontend/src/lib/api/translate/jobs.ts +++ b/frontend/src/lib/api/translate/jobs.ts @@ -77,7 +77,6 @@ export async function updateJob(jobId: string, payload: Record [Api.ApiModule.DeleteApi] @@ -92,7 +91,6 @@ export async function deleteJob(jobId: string): Promise { // #region Api.Jobs.DuplicateJob [C:2] [TYPE Function] [SEMANTICS translate, jobs, duplicate] // @BRIEF Duplicate a translation job (deep copy including configuration). -// @PRE jobId is a non-empty string. // @POST Returns duplicated job response. // @SIDE_EFFECT Creates a new job as a copy of the specified one. // @RELATION DEPENDS_ON -> [Api.ApiModule.PostApi] diff --git a/frontend/src/lib/api/translate/runs.ts b/frontend/src/lib/api/translate/runs.ts index 4c2222afa..7e41d36ef 100644 --- a/frontend/src/lib/api/translate/runs.ts +++ b/frontend/src/lib/api/translate/runs.ts @@ -35,7 +35,6 @@ export interface AllRunsQueryOptions extends RunQueryOptions { // #region Api.Runs.TriggerRun [C:2] [TYPE Function] [SEMANTICS translate, runs, trigger] // @BRIEF Trigger a new translation run for a job. -// @PRE jobId is a non-empty string. // @POST Returns created run response with status. // @SIDE_EFFECT Starts async translation processing on the backend. // @RELATION DEPENDS_ON -> [Api.ApiModule.PostApi] @@ -104,7 +103,6 @@ export async function fetchRunStatus(runId: string): Promise { // #region Api.Runs.FetchRunHistory [C:2] [TYPE Function] [SEMANTICS translate, runs, history] // @BRIEF Fetch paginated run history for a job. -// @PRE jobId is a non-empty string. // @POST Returns paginated run list. // @RELATION DEPENDS_ON -> [Api.ApiModule.FetchApi] export async function fetchRunHistory(jobId: string, options: RunHistoryQueryOptions = {}): Promise { @@ -238,7 +236,6 @@ export async function fetchRunDetail(runId: string): Promise { // #region Api.Runs.FetchJobMetrics [C:2] [TYPE Function] [SEMANTICS translate, metrics, job] // @BRIEF Fetch aggregate metrics for a translation job. -// @PRE jobId is a non-empty string. // @POST Returns metrics object with counts and stats. // @RELATION DEPENDS_ON -> [Api.ApiModule.FetchApi] export async function fetchJobMetrics(jobId: string): Promise { diff --git a/frontend/src/lib/api/translate/schedules.ts b/frontend/src/lib/api/translate/schedules.ts index cb2235ae8..5410732dc 100644 --- a/frontend/src/lib/api/translate/schedules.ts +++ b/frontend/src/lib/api/translate/schedules.ts @@ -17,7 +17,6 @@ function normalizeTranslateError(error: unknown, defaultMessage: string = 'Trans // #region Api.Schedules.FetchSchedule [C:2] [TYPE Function] [SEMANTICS translate, schedules, fetch] // @BRIEF Fetch the cron schedule for a translation job. -// @PRE jobId is a non-empty string. // @POST Returns schedule configuration or null if no schedule exists. // @RELATION DEPENDS_ON -> [Api.ApiModule.FetchApi] export async function fetchSchedule(jobId: string): Promise { @@ -46,7 +45,6 @@ export async function setSchedule(jobId: string, payload: Record [Api.ApiModule.DeleteApi] @@ -91,7 +89,6 @@ export async function disableSchedule(jobId: string): Promise { // #region Api.Schedules.FetchNextExecutions [C:2] [TYPE Function] [SEMANTICS translate, schedules, next-executions] // @BRIEF Fetch upcoming execution times for a job's schedule. -// @PRE jobId is a non-empty string. // @POST Returns array of upcoming datetime strings. // @RELATION DEPENDS_ON -> [Api.ApiModule.FetchApi] export async function fetchNextExecutions(jobId: string, n: number = 3): Promise { diff --git a/frontend/src/lib/components/layout/TaskDrawer.svelte b/frontend/src/lib/components/layout/TaskDrawer.svelte index b609a78e4..663679ad0 100644 --- a/frontend/src/lib/components/layout/TaskDrawer.svelte +++ b/frontend/src/lib/components/layout/TaskDrawer.svelte @@ -19,12 +19,6 @@ - - - - - - - + diff --git a/scripts/semantic_health.py b/scripts/semantic_health.py new file mode 100644 index 000000000..fc2923a1a --- /dev/null +++ b/scripts/semantic_health.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# #region Tooling.SemanticHealth [C:3] [TYPE Module] [SEMANTICS grace,health,zombie-mode] +# @BRIEF Count #region pairs, dual [C:N], duplicate IDs, and copy-paste @-tags without treating missing tags as errors (INV_9). +# @RELATION BINDS_TO -> [Std.Semantics.Core] +"""Structural GRACE health for zombie-mode (no Axiom required). + +INV_9: missing @-tags are never errors. This script reports broken pairs, +dual [C:N], duplicate IDs, oversized files, and optional copy-paste tag text. + +Exit codes: + 0 ok (or warnings only) + 1 --strict-pairs and at least one mismatched #region/#endregion in production src +""" +from __future__ import annotations + +import argparse +import collections +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKIP_PARTS = { + ".git", + ".venv", + "node_modules", + "__pycache__", + ".axiom", + "coverage", + "coverage_html_frontend", + "dist", + "build", +} + +PROD_ZONES = ( + ROOT / "backend" / "src", + ROOT / "frontend" / "src", + ROOT / "agent" / "src", + ROOT / "shared" / "src", +) +ALL_ZONES = PROD_ZONES + ( + ROOT / "backend" / "tests", + ROOT / "frontend" / "tests", + ROOT / "agent" / "tests", +) + +REGION_OPEN = re.compile( + r"^\s*#\s*#region\s+(\S+)(?:\s+\[C:(\d+)\])?", + re.I | re.M, +) +REGION_CLOSE = re.compile(r"^\s*#\s*#endregion\s+(\S+)", re.I | re.M) +HTML_OPEN = re.compile( + r"^\s*)?\s*$", + re.I, +) + + +def _skip(path: Path) -> bool: + return any(part in SKIP_PARTS for part in path.parts) or "__tests__" in path.parts + + +def _iter_files(zones: tuple[Path, ...]) -> list[Path]: + out: list[Path] = [] + for zone in zones: + if not zone.exists(): + continue + for path in zone.rglob("*"): + if not path.is_file() or _skip(path): + continue + if path.suffix not in {".py", ".svelte", ".ts", ".js"}: + continue + out.append(path) + return out + + +def _opens_closes(path: Path, text: str) -> tuple[list[str], list[str]]: + if path.suffix == ".py": + opens = [m.group(1) for m in REGION_OPEN.finditer(text)] + closes = [m.group(1) for m in REGION_CLOSE.finditer(text)] + elif path.suffix == ".svelte": + opens = [m.group(1) for m in HTML_OPEN.finditer(text)] + [ + m.group(1) for m in JS_OPEN.finditer(text) + ] + closes = [m.group(1) for m in HTML_CLOSE.finditer(text)] + [ + m.group(1) for m in JS_CLOSE.finditer(text) + ] + else: + opens = [m.group(1) for m in JS_OPEN.finditer(text)] + closes = [m.group(1) for m in JS_CLOSE.finditer(text)] + return opens, closes + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--strict-pairs", + action="store_true", + help="exit 1 if production src has mismatched region pairs", + ) + parser.add_argument( + "--all-zones", + action="store_true", + help="include tests (default: production src only)", + ) + args = parser.parse_args() + + zones = ALL_ZONES if args.all_zones else PROD_ZONES + mismatched: list[str] = [] + dual_c: list[str] = [] + oversized: list[str] = [] + ids: list[str] = [] + tag_text: dict[str, collections.Counter] = collections.defaultdict(collections.Counter) + + for path in _iter_files(zones): + text = path.read_text(encoding="utf-8", errors="replace") + rel = str(path.relative_to(ROOT)) + lines = text.count("\n") + 1 + if lines > 400 and "__tests__" not in path.parts: + oversized.append(f"{lines:5d} {rel}") + opens, closes = _opens_closes(path, text) + ids.extend(opens) + if len(opens) != len(closes): + mismatched.append(f"{rel} open={len(opens)} close={len(closes)}") + for i, line in enumerate(text.splitlines(), 1): + if DUAL_C.search(line): + dual_c.append(f"{rel}:{i}") + m = TAG_LINE.match(line) + if m: + body = re.sub(r"\s+", " ", m.group(2)).strip().lower() + if len(body) >= 24: + tag_text[m.group(1).upper()][body] += 1 + + dup_ids = [(n, i) for i, n in collections.Counter(ids).items() if n > 1] + dup_ids.sort(reverse=True) + + print("== GRACE structural health ==") + print(f"root: {ROOT}") + print(f"zones: {', '.join(str(z.relative_to(ROOT)) for z in zones if z.exists())}") + print(f"regions: {len(ids)}") + print(f"mismatched pairs: {len(mismatched)}") + for row in mismatched[:40]: + print(f" {row}") + if len(mismatched) > 40: + print(f" … {len(mismatched) - 40} more") + print(f"dual [C:N]: {len(dual_c)}") + for row in dual_c[:20]: + print(f" {row}") + print(f"files >400 LOC (non-__tests__): {len(oversized)}") + for row in sorted(oversized, reverse=True)[:15]: + print(f" {row}") + print(f"duplicate contract IDs: {len(dup_ids)}") + for n, cid in dup_ids[:15]: + print(f" {n} {cid}") + + print("== copy-paste tag heuristic (advisory, not a failure) ==") + for tag, counter in tag_text.items(): + clones = [(n, t) for t, n in counter.items() if n >= 3] + clones.sort(reverse=True) + if not clones: + continue + print(f"{tag}: {len(clones)} texts reused ≥3 times") + for n, t in clones[:5]: + print(f" {n} {t[:100]}") + + print("INV_9: missing @-tags are not reported as errors.") + if args.strict_pairs and mismatched and not args.all_zones: + return 1 + if args.strict_pairs and args.all_zones and mismatched: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +# #endregion Tooling.SemanticHealth diff --git a/scripts/sync-skills.sh b/scripts/sync-skills.sh new file mode 100755 index 000000000..f5a3efd73 --- /dev/null +++ b/scripts/sync-skills.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE_DIR="${ROOT_DIR}/.agents/skills/" +TARGET_DIR="${ROOT_DIR}/.kilo/skills/" + +if [[ ! -d "${SOURCE_DIR}" ]]; then + printf 'Source skills directory not found: %s\n' "${SOURCE_DIR}" >&2 + exit 1 +fi + +mkdir -p "${TARGET_DIR}" +rsync --archive --delete "${SOURCE_DIR}" "${TARGET_DIR}" +printf 'Synchronized skills: %s -> %s\n' "${SOURCE_DIR}" "${TARGET_DIR}" diff --git a/specs/036-agent-test-stabilization/spec.md b/specs/036-agent-test-stabilization/spec.md index 40febcd3e..2d1a1a362 100644 --- a/specs/036-agent-test-stabilization/spec.md +++ b/specs/036-agent-test-stabilization/spec.md @@ -7,6 +7,8 @@ @RELATION DEPENDS_ON -> [Doc.Adr.ADR0008] @RATIONALE Dashboard test scenario generation produces durable artifacts and baseline approval flows, so the agent must be a recoverable execution surface rather than a best-effort chat stream. @REJECTED Starting dashboard scenario generation directly on top of unstabilized chat behavior — rejected because failures would be indistinguishable across Gradio transport, context passing, artifact preview, and HITL gates. +@REJECTED Long-term Gradio chat as the primary agent interaction surface — rejected 2026-08-24: durable-runtime contracts (AgentRun, AgentAction provenance, ActionApprovalGate) persist unchanged, but the conversational transport retires in favor of external MCP clients per `specs/050-mcp-interface/spec.md`. +@REJECTED AGSTAB-FR-009 mandatory pre-VLM screenshot masking — rejected 2026-08-24: all LLM/VLM providers run locally inside the enterprise trust perimeter; PII exposure to local models is an accepted residual risk. Masking becomes an optional capture option for display hygiene, never a gate for analysis. ## Navigation (DSA Indexer keywords) @SEMANTICS: spec, requirements, feature, agent, gradio, langgraph, scenario, artifact, hitl, progress, dashboard-testing @@ -87,7 +89,7 @@ - **AGSTAB-FR-006**: Permission-denied operations MUST emit `permission_denied` metadata and never present a confirm button for unauthorized actions. - **AGSTAB-FR-007**: The existing Gradio/LangGraph chat, streaming, confirmation, context, and guardrail flows from 033/035 MUST remain compatible. - **AGSTAB-FR-008**: A live smoke test MUST verify context → stream → tool event → draft artifact preview → confirmation/denial recovery. -- **AGSTAB-FR-009**: Screenshot evidence intended for external LLM/VLM analysis MUST be masked (DOM selectors from dashboard configuration) before transmission; the original unmasked capture MUST remain as a separate artifact for audit; text-based redaction alone is insufficient for image payloads. +- **AGSTAB-FR-009**: [SUPERSEDED 2026-08-24 — see @REJECTED in header] Mandatory masking of screenshot evidence before LLM/VLM analysis is excluded: providers are locally deployed inside the enterprise perimeter, so unmasked captures MAY be submitted directly. Masked derivatives (`register_masked_derivative`) remain an OPTIONAL capability for display/export hygiene and MUST NOT be a precondition for VLM submission, evidence registration, or any gate. - **AGSTAB-FR-010**: The runtime MUST support typed investigation/revalidation/remediation intents linked to the shared InvestigationCase contract. Events enter Investigation Queue and MUST NOT automatically start an agent chat or tool run. - **AGSTAB-FR-011**: Every agent tool call MUST persist AgentAction provenance and pass deterministic ACL, environment, capacity, ActionRegistry, and approval-policy checks before execution. - **AGSTAB-FR-012**: Delegated policy MAY authorize the agent to execute read, diagnostic, authorized fixture mutation, draft, and executable-revision writes autonomously. It MUST NOT bypass deterministic validators, immutable revision creation, or a required ActionApprovalGate. @@ -101,7 +103,7 @@ - **ScenarioProgressEvent**: Structured metadata event representing a stage or step in scenario generation. - **DraftArtifactRef**: Previewable reference to generated content that is not yet persisted as an approved repository artifact. - **ScreenshotEvidence**: A `DraftArtifact` of kind `screenshot_evidence` carrying capture metadata (viewport, filters_hash, tab identifier, readiness policy, masking config), a content-addressable digest, and an opaque preview URL. Never contains raw storage paths. -- **CaptureMeta**: Structured metadata attached to screenshot artifacts: viewport dimensions, device scale factor, capture method, tab/filter context hash, readiness strategy, browser version, and applied masking selectors. +- **CaptureMeta**: Structured metadata attached to screenshot artifacts: viewport dimensions, device scale factor, capture method, tab/filter context hash, readiness strategy, browser version, and optional applied masking selectors (empty when masking is unused). - **ActionApprovalGate**: Payload-bound authorization envelope for actions that policy does not delegate; rendered inline in its owning work surface. - **InvestigationCase / AgentAction**: Shared 036–047 case and delegated-tool-action contracts defined in `contracts/investigation-cases.md`. @@ -120,4 +122,13 @@ - ✅ **Подтверждённое переиспользование**: evidence-адаптер зовёт `Plugin.Service.ScreenshotService`, `Plugin.Service.LLMClient` и `Plugin.Service.RedactionService` (relation'ы зафиксированы в `contracts/evidence.md`). Второй Playwright/LLM-клиент запрещён. - 🟡 Зависимость: 038 Phase 10 (T057–T059) и 040 Phase 9 (T075–T079) потребляют 036 evidence bridge и ApprovalGate; до их мерджа screenshot/VLM-ветки 038 остаются runtime-заглушками, хотя 036-слой готов. +## Drift Amendment — MCP Interface (2026-08-24) + +Gradio chat retirement is formalized in `specs/050-mcp-interface/spec.md`. Carry-over mapping: + +- **Preserved**: `AgentRun`, `AgentRunEvent`, `DraftArtifact`, `ApprovalGate`, AgentAction provenance, deterministic pre-execution checks (AGSTAB-FR-011/012) — MCP tool invocations produce the same server-side records. +- **Retired with the chat**: Gradio transport, streaming progress rendering, LangGraph `interrupt_before` confirmation loop and `_confirmation.py`; HITL continues through durable gates rendered by web surfaces or MCP decision tools. +- **Re-targeted**: AGSTAB-FR-003 progress-event UI requirements lose their chat consumer; structured events remain the contract for any future server-side observer. AGSTAB-FR-008 smoke test is superseded by 050 Phase 2 e2e. +- **PII posture (2026-08-24)**: AGSTAB-FR-009 superseded — masking is optional; local-only LLM/VLM deployment inside the enterprise perimeter is the accepted confidentiality boundary for evidence. The mask-miss detection edge case is withdrawn accordingly. + #endregion AgentTestStabilization.Spec diff --git a/specs/037-superset-baseline-engine/spec.md b/specs/037-superset-baseline-engine/spec.md index d0b4a2201..fa1d7f7dc 100644 --- a/specs/037-superset-baseline-engine/spec.md +++ b/specs/037-superset-baseline-engine/spec.md @@ -6,6 +6,7 @@ @RELATION DEPENDS_ON -> [AgentTestStabilization.Spec] @RATIONALE Dashboard test assertions must validate the same Superset-side chart or dataset execution path that powers dashboards, not a parallel SQL path that can diverge from Superset filter/query semantics. @REJECTED Direct SQL execution for **chart/baseline truth** — rejected because this feature requires stable Superset dataset/chart execution and filter fidelity. This does not prohibit 038 validated SqlEvidenceSpec for an independent source-mart oracle. +@REJECTED A second bespoke chat runtime as the only consumer of baseline tools — superseded 2026-08-24: capture/approval/verification tools join the MCP catalog as thin forwarders per `specs/050-mcp-interface/spec.md`; server-side hash computation and gate policy are unchanged. ## Navigation (DSA Indexer keywords) @SEMANTICS: spec, requirements, feature, superset, baseline, chart-data, dataset, filters, normalization, dashboard-testing @@ -128,4 +129,9 @@ **Закрытие**: задачи T080–T081 в `tasks.md` Phase 10 (deploy-hook trigger + GET-эндпоинты) — дискретный контур оценки метрик работает «напрямую», но пайплайн-автоматизация и read-API не дописаны. +## Drift Amendment — MCP Interface (2026-08-24) + +- Baseline tools (`capture_baseline_candidate`, approval lifecycle, `create_verification_run`) are exposed 1:1 through the MCP catalog (050 Phase 1 parity); no behavioral change to engine semantics. +- The vendored `research/mcp-superset` server is explicitly NOT adopted as an alternative surface: it bypasses this feature's server-side hash computation, release pinning and immutability rules. + #endregion SupersetBaselineEngine.Spec diff --git a/specs/038-dashboard-scenario-model/spec.md b/specs/038-dashboard-scenario-model/spec.md index 9992fcc35..3f80e34ed 100644 --- a/specs/038-dashboard-scenario-model/spec.md +++ b/specs/038-dashboard-scenario-model/spec.md @@ -7,6 +7,7 @@ @RATIONALE Unique dashboard tests need a stable intermediate model between agent reasoning and generated artifacts; direct LLM-to-code generation is not reviewable or safely composable. @REJECTED Asking users to choose low-level outputs such as Playwright vs SQL vs XLSX — rejected because each dashboard scenario is goal-oriented; the agent proposes inspectable compiled evidence/transform/assertion steps. @REJECTED Direct generation of executable scripts without a validated scenario graph — rejected because it hides missing selectors, baseline refs, and unsafe steps until runtime. +@REJECTED Agent-chat-only authoring entry — superseded 2026-08-24: compile/validate/resolve/draft-pack/save become callable through the MCP interface per `specs/050-mcp-interface/spec.md`; the deterministic validator remains the sole gateway to any artifact. ## Navigation (DSA Indexer keywords) @SEMANTICS: spec, requirements, feature, scenario, graph, dashboard-testing, checklist, validation, artifacts @@ -84,8 +85,8 @@ **Independent Test**: Generate a scenario with screenshot capture, VLM analysis, and human disposition; verify typed findings and auditable dispositions. **Acceptance**: -1. **Given** a screenshot step **When** capture executes **Then** a reproducible `ScreenshotCaptureSpec` (target, viewport, readiness, masking, max wait) is honored and artifacts are registered. -2. **Given** a masked screenshot **When** VLM analysis runs **Then** typed `VlmFinding[]` (severity, region, confidence, model/prompt provenance) are returned; raw prose is never treated as step state. +1. **Given** a screenshot step **When** capture executes **Then** a reproducible `ScreenshotCaptureSpec` (target, viewport, readiness, optional masking, max wait) is honored and artifacts are registered. +2. **Given** a screenshot (masked or unmasked — providers are local) **When** VLM analysis runs **Then** typed `VlmFinding[]` (severity, region, confidence, model/prompt provenance) are returned; raw prose is never treated as step state. 3. **Given** a human checkpoint references VLM finding ids **When** the user disposes **Then** confirm/false_positive/inconclusive is typed and auditable, and disposition never mutates the graph structure. ## Edge & Failure Cases @@ -121,8 +122,8 @@ - **AGSCN-FR-007**: The model MUST allow manual/human checkpoint steps where automation is unsafe, unavailable, or underspecified. - **AGSCN-FR-008**: Scenario output MUST be deterministic for the same dashboard query model, checklist template, baseline catalog, and user parameters. - **AGSCN-FR-009**: The scenario model MUST remain implementation-neutral and must not require the user to choose low-level artifacts such as Playwright, XLSX, or API output upfront. -- **AGSCN-FR-010**: Screenshot steps MUST carry a capture SPECIFICATION: target (tab/viewport), viewport dimensions, readiness strategy, masking selectors, and max wait. **Execution of capture is owned by 044** (ScenarioExecution CaptureService delegating to `Plugin.Service.ScreenshotService`), with artifacts `owner_type=scenario_run`; 038 defines the spec, not the runtime path. -- **AGSCN-FR-011**: Visual-analysis steps MUST carry a typed `VlmAnalysisSpec` (profile/provider/model/prompt template/hash/confidence). **Runtime VLM submission and `VlmFinding` production are owned by 044**, reusing `Plugin.Service.LLMClient` resolved through `Services.LlmProvider.LLMProviderService` (multimodal-required, encrypted-key handling, JSON mode) and redacting raw responses via `Plugin.Service.RedactionService`. A stub/default submit returning empty findings without a real provider call is incomplete. +- **AGSCN-FR-010**: Screenshot steps MUST carry a capture SPECIFICATION: target (tab/viewport), viewport dimensions, readiness strategy, OPTIONAL masking selectors (relaxed 2026-08-24 — local-only providers; masking is display hygiene, not a gate), and max wait. **Execution of capture is owned by 044** (ScenarioExecution CaptureService delegating to `Plugin.Service.ScreenshotService`), with artifacts `owner_type=scenario_run`; 038 defines the spec, not the runtime path. +- **AGSCN-FR-011**: Visual-analysis steps MUST carry a typed `VlmAnalysisSpec` (profile/provider/model/prompt template/hash/confidence). **Runtime VLM submission and `VlmFinding` production are owned by 044**, reusing `Plugin.Service.LLMClient` resolved through `Services.LlmProvider.LLMProviderService` (multimodal-required, encrypted-key handling, JSON mode); response redaction via `Plugin.Service.RedactionService` is OPTIONAL since 2026-08-24 (local-only providers). A stub/default submit returning empty findings without a real provider call is incomplete. - **AGSCN-FR-011a**: Read-only `SqlEvidenceSpec` MAY be authored during creation/edit/revalidation or investigation proposal only. Save MUST require AST/policy/schema/preview validation. A ScenarioRun executes exactly the saved SQL template via the Superset SQL Lab adapter with typed bindings; runtime LLM SQL rewrite, relation/projection/join/filter mutation and credential handoff are forbidden. - **AGSCN-FR-011b**: `TransformSpec` MUST use only the bounded versioned DSL and `ComparisonSpec`/`AssertionSpec` MUST compare declared evidence refs. Arbitrary Python/code is forbidden. - **AGSCN-FR-011c**: `AgentEvaluationSpec` MAY cover only declared semantic/visual/ambiguous checks. It MUST pin model/prompt/evidence/input/tool access/output schema and DecisionPolicy; it cannot alter graph, SQL/DSL, orchestration, lifecycle or mutations. @@ -142,7 +143,7 @@ - **ChecklistCase**: Normalized item from the research checklist with capability tags and expected verification semantics. - **CapabilityMapping**: Decision record mapping dashboard capabilities to applicable checklist cases and step templates. - **ScenarioValidationResult**: Structured validator output with errors, warnings, blockers, and graph coverage. -- **ScreenshotCaptureSpec**: Capture CONFIGURATION for a screenshot step — viewport, target tab, readiness strategy, masking selectors, and timeouts. (Execution owned by 044.) +- **ScreenshotCaptureSpec**: Capture CONFIGURATION for a screenshot step — viewport, target tab, readiness strategy, optional masking selectors, and timeouts. (Execution owned by 044.) - **VlmAnalysisSpec**: Typed SPEC of visual analysis — provider/model/prompt/hash/confidence. (Runtime `VlmFinding` owned by 044.) - **HumanDisposition**: Typed human decision on a VLM finding — confirm, false_positive, or inconclusive (044 HumanCheckpoint). @@ -174,4 +175,10 @@ **Закрытие**: cross-spec pass привёл 038 к роли чистого IR/compiler слоя; все runtime-контуры (execution, evidence owner_type=scenario_run, HumanCheckpoint) закрываются 044. Отдельный `038/validation.md` PASS аннулирован как self-contradictory — см. validation.md. +## Drift Amendment — MCP Interface (2026-08-24) + +- The authoring chain (compile/validate/resolve/draft-pack/request-save) is callable from external MCP clients (050); the compiler/validator contracts, determinism and `needs_context`/`needs_selector` semantics are unchanged. +- "Agent" throughout this spec now reads as any delegated actor — external MCP client or in-product surface — always behind the same deterministic validator and provenance rules. +- **PII posture**: masking selectors in `ScreenshotCaptureSpec` and response redaction in the VLM path are OPTIONAL (2026-08-24) — all LLM/VLM providers run locally inside the trust perimeter. + #endregion DashboardScenarioModel.Spec diff --git a/specs/039-dashboard-scenario-ui/spec.md b/specs/039-dashboard-scenario-ui/spec.md index 2c5734e49..e2190b6e6 100644 --- a/specs/039-dashboard-scenario-ui/spec.md +++ b/specs/039-dashboard-scenario-ui/spec.md @@ -8,6 +8,7 @@ @RELATION DEPENDS_ON -> [DashboardScenarioModel.Spec] @RATIONALE Users should approve a business-level dashboard test scenario and required parameters; low-level tool chains are visible for trust but selected by the agent and scenario validator. @REJECTED Dropdowns such as "Playwright UI tests" vs "SQL checks" vs "XLSX checks" — rejected because each dashboard requires a unique cross-tool program. Validated immutable SQL evidence is visible in the program, not chosen as a loose UI mode. +@REJECTED Persistent chat workspace as the mandatory creation UX — superseded 2026-08-24 by external MCP clients plus non-chat surfaces (042/043) per `specs/050-mcp-interface/spec.md`; the dashboard entry action becomes an explicit handoff surface. ## Navigation (DSA Indexer keywords) @SEMANTICS: spec, requirements, feature, ux, agent, scenario, dashboard-testing, artifacts, baseline @@ -196,4 +197,12 @@ These entities are independent of the agent workspace. They consume `Verificatio - ✅ **T057**: `DashboardDetailModel.loadVerificationRuns()` + `VerificationHistoryList` привязан на `/dashboards/[id]` (потребляет 037 T081 GET `/verification/history`); `DashboardDetailModel.test.ts` = 67 passed. - 🟡 **T058 — открыт (deferred)**: verify-action на PREPROD требует `repository_id`, которого нет в dashboard metadata, и отдельной deployment-страницы, которой нет во frontend. Требует dashboard→git-repository linkage + deployment surface. Зафиксировано в tasks.md как известный blocker. +## Drift Amendment — MCP Interface (2026-08-24) + +Gradio chat retirement is formalized in `specs/050-mcp-interface/spec.md`. + +- **Superseded**: AGUI-FR-001..013 as a mandatory in-product chat workspace; scenario preview, parameters and artifact review remain available in non-chat surfaces (042 registry detail, 043 editor). +- **Entry action**: «Создать сценарий тестирования» opens a HandoffSurface — connection instructions plus a copyable prompt carrying dashboard context (`objectType/objectId/envId/route/intent`) for an external MCP client; it never links to `/agent`. +- **Unchanged**: pipeline verification views (AGUI-FR-014..016), Svelte 5 conventions for remaining surfaces, DTO-only component contracts. + #endregion DashboardScenarioUi.Spec diff --git a/specs/040-dashboard-load-testing/spec.md b/specs/040-dashboard-load-testing/spec.md index 710ed29fe..6b72ff53d 100644 --- a/specs/040-dashboard-load-testing/spec.md +++ b/specs/040-dashboard-load-testing/spec.md @@ -10,6 +10,7 @@ @REJECTED Treating load runs as 036 AgentRun instances — rejected because load execution is non-conversational, fan-out by design, and would pollute run/recovery semantics with thousands of pseudo-conversations. @REJECTED Writing load results into the 037 baseline catalog — rejected because load executions measure latency and consistency, not truth; polluting baselines with load samples would corrupt immutability detection. @REJECTED Unbounded client-declared concurrency — rejected because a single misconfigured run could saturate the Superset/KXD connection pool and degrade production for all users. +@RATIONALE Load testing never depended on the chat agent; after the 050 MCP drift, LOAD-FR-020 delegated experiments and diagnostics are exercisable by any governed actor including external MCP clients, with caps, breaker and PROD gate unchanged. ## Navigation (DSA Indexer keywords) @SEMANTICS: spec, requirements, feature, load-testing, concurrency, variations, circuit-breaker, latency, blast-radius, dashboard-testing @@ -166,4 +167,8 @@ - ✅ Verified: `tests/services/load_testing/test_executor_runtime.py` (5 тестов, включая real-execution→LoadExecution), полный `tests/services/load_testing/` = 76 passed, ruff clean. - 🟡 Осталось для operational: живые/фикстурные Superset-прогоны (exit gate 6), интеграционный тест capacity с ordinary-запросами (T077 extension). +## Drift Amendment — MCP Interface (2026-08-24) + +- No chat dependency existed; load surfaces stay web-first. MCP exposure of read-only run/comparison queries is optional future catalog work (050), never bypassing LOAD-FR-002 caps or the PROD gate. + #endregion DashboardLoadTesting.Spec diff --git a/specs/041-dataset-lineage-blast-radius/spec.md b/specs/041-dataset-lineage-blast-radius/spec.md index 2509922a7..2eaf819a9 100644 --- a/specs/041-dataset-lineage-blast-radius/spec.md +++ b/specs/041-dataset-lineage-blast-radius/spec.md @@ -10,6 +10,7 @@ @REJECTED Extending DashboardQueryModel with reverse lookups — rejected because QueryModel is a per-dashboard projection; embedding cross-dashboard lineage would couple every inspection to fleet-wide state and break its deterministic, single-dashboard fingerprint semantics (037 AGBASE-FR-001). @REJECTED Deriving lineage at query time by scanning all dashboards per request — rejected because it is O(fleet) per lookup, non-deterministic under concurrent dashboard edits, and unusable inside hot paths (load-profile preview, PROD gates, release checks). @REJECTED Propagating staleness by auto-invalidating dependent baselines — rejected because a dataset schema change does not always invalidate downstream truth (additive columns are safe); blind invalidation destroys approved baselines and violates the 037 immutability model. Impact must be classified and surfaced, not silently enforced. +@RATIONALE Blast-radius explanation (LIN-FR-021) is consumed by external MCP clients after the 050 drift; the index, severity matrix and fan-out contracts are unchanged and remain server-owned. ## Navigation (DSA Indexer keywords) @SEMANTICS: spec, requirements, feature, lineage, blast-radius, dataset, schema-change, staleness-propagation, deprecation, fan-out, dashboard-testing @@ -187,4 +188,8 @@ - ✅ **T048 (decision)**: `lineage_index_enabled` остаётся **false** с задокументированным rationale — включение по умолчанию вызвало бы post-sync Superset detail-calls для каждой env на каждом цикле; flip только после proof стабильности indexer'а на живом fleet. Consumers (040 PROD gate, 039 pipeline views) трактуют disabled index как пустой read-model со `stale_notice`. - ✅ Verification: lineage + api vitest = 236 passed, vite build OK, eslint чист для изменённого кода. +## Drift Amendment — MCP Interface (2026-08-24) + +- "Agent explains blast radius / builds revalidation plans" (LIN-FR-021) continues with external MCP clients as the delegated actor; impact records, deprecation and fan-out remain deterministic server artifacts that no client can mutate outside governed tools. + #endregion DatasetLineageBlastRadius.Spec diff --git a/specs/042-dashboard-scenario-registry/spec.md b/specs/042-dashboard-scenario-registry/spec.md index 7bee78977..d5fae1d5a 100644 --- a/specs/042-dashboard-scenario-registry/spec.md +++ b/specs/042-dashboard-scenario-registry/spec.md @@ -9,6 +9,7 @@ @RATIONALE 038/039 cover scenario generation but not post-creation management; a scenario is currently an ephemeral agent-session artifact materialized to git, with no registry, list, detail, versions, or lifecycle status. Without a persisted source of truth, the editor (043) and runner (044) have nothing stable to read from. @REJECTED Treating a scenario as a git-file-only artifact without a registry — rejected because search/filter/ownership/status/stale-detection/versions need a queryable projection, and a UI without a registry would be a facade over nonexistent runtime (the exact anti-pattern 042 exists to prevent). @REJECTED Hard-deleting a scenario that has run history — rejected because audit trail and reproducibility must survive; the canonical lifecycle terminal is archive. +@RATIONALE SCREG-FR-011 delegated actors explicitly include external MCP principals after the 050 drift; registry contracts (revisions, activation, staleness, ACL) are actor-agnostic and unchanged. ## Navigation (DSA Indexer keywords) @SEMANTICS: spec, requirements, feature, scenario, registry, lifecycle, catalog, revision, stale @@ -150,4 +151,8 @@ current tree. This establishes a partial implementation, not feature closure. - `[ ]` The final quickstart/scoped verification and acceptance audit have not been run in this audit. - `[ ]` No current runtime/browser evidence is retained; see `WORKSTATE-043-047.md` for audit scope. +## Drift Amendment — MCP Interface (2026-08-24) + +- Registry is the primary non-chat consumer of MCP-created scenarios: revisions saved through 050 tools land here with the same provenance, lifecycle and staleness semantics as any other actor. + #endregion ScenarioRegistry.Spec diff --git a/specs/043-dashboard-scenario-editor/spec.md b/specs/043-dashboard-scenario-editor/spec.md index 02dbd2fc6..0bb8723e7 100644 --- a/specs/043-dashboard-scenario-editor/spec.md +++ b/specs/043-dashboard-scenario-editor/spec.md @@ -7,6 +7,7 @@ @RATIONALE 038 defines the DTOs but no lifecycle editing UX. A scenario must be viewable and editable outside the agent chat, with every durable edit producing a new immutable revision and delegated policy determining whether an inline approval is required. @REJECTED Agent-only editing (no visual surface) — rejected because users need to review and adjust a scenario without re-prompting the agent each time. @REJECTED Unconstrained free-form DAG/assertion editor — rejected because it could inject SQL/raw baselines/unsafe paths, violating 038 safety invariants; assertions use constrained editors and generated executable stays read-only. +@REJECTED Chat-bound "Edit with agent" as the only proposal channel — superseded 2026-08-24: proposals are creatable through MCP tools per `specs/050-mcp-interface/spec.md`; server-stored WorkingDraft, digest binding and SCEDIT-FR-009 no-arbitrary-draft-save constraints stand unchanged. ## Navigation (DSA Indexer keywords) @SEMANTICS: spec, requirements, feature, ux, scenario, editor, visual, revision, agent @@ -152,4 +153,8 @@ present. The hybrid editor therefore exists structurally but is not verified as - `[~]` Revalidation depends on 042 staleness input whose upstream 037/041 integration is unproven. - `[ ]` Feature closure is blocked by incomplete 044 execution and unperformed independent verification. +## Drift Amendment — MCP Interface (2026-08-24) + +- "Edit with agent" (Story 5) continues with external MCP clients: the proposal/WorkingDraft flow, stale-proposal rejection and gate-bound saves are unchanged; only the conversation medium moves out of the product. + #endregion ScenarioEditor.Spec diff --git a/specs/044-dashboard-scenario-execution/spec.md b/specs/044-dashboard-scenario-execution/spec.md index 91484e767..32b2cfd29 100644 --- a/specs/044-dashboard-scenario-execution/spec.md +++ b/specs/044-dashboard-scenario-execution/spec.md @@ -10,6 +10,7 @@ @REJECTED Agent-orchestrated step execution — rejected because each step would be an LLM call and could rewrite program flow. Explicit versioned AgentEvaluationSpec inside a deterministic step boundary is allowed. @REJECTED Reusing AgentRun as the execution run — rejected because AgentRun is the creation-process run; ScenarioRun is the created-test execution. Reusing VerificationRun — rejected because it is release-pipeline category verification, not arbitrary-DAG execution. @REJECTED `human` as a dispatched executor — rejected; it is a runner-lifecycle suspend/resume control primitive, not a side-effect executor. +@RATIONALE The runner was already agent-free; after the 050 MCP drift the authoring/investigation boundary (SCEX-FR-012) is inherited by external MCP clients with zero change to orchestration, executors or capacity admission. ## Navigation (DSA Indexer keywords) @SEMANTICS: spec, requirements, feature, scenario, execution, run, step, runner, engine, resume, human @@ -331,4 +332,9 @@ receipts, cancellation/reconciliation, mutation policy, readiness checks and man not claim runtime implementation: T028-T034, T040-T042, T042b and a real PREPROD canary remain required before the BrowserProvider can be called production-ready. +## Drift Amendment — MCP Interface (2026-08-24) + +- Unaffected structurally: ScenarioRun, executors, capacity and gates never referenced the chat runtime. MCP clients author scenarios before runs (038 chain) and investigate after terminal signals (047 cases) through governed tools only; `manual_run_only` and PROD gating apply regardless of the actor. +- **SCEX-FR-013 re-scoped for MCP (decision 2026-08-24)**: HumanCheckpoint disposition MAY be submitted through governed MCP decision tools (`decide_checkpoint`) when driven by an authenticated user principal — it remains a manual analyst decision, CAS-versioned and audited, equivalent to the 045 monitor path. The prohibition that stands unchanged: no automated origin (scheduled/deploy/release/ETL/API/service-principal) may create, consume or bypass a checkpoint, and the agent-as-autonomous-planner still cannot choose a disposition on its own. + #endregion ScenarioExecution.Spec diff --git a/specs/045-dashboard-run-monitor/spec.md b/specs/045-dashboard-run-monitor/spec.md index 55ae8e896..8f2169d1e 100644 --- a/specs/045-dashboard-run-monitor/spec.md +++ b/specs/045-dashboard-run-monitor/spec.md @@ -8,6 +8,7 @@ @RATIONALE After creating a scenario the user needs to run it and understand results; 042/044 provide the backend run/result model, 045 renders it live with recovery, evidence, human actions, history, and comparison (reusing 040 LoadRunComparison UX ideas). Without this, execution is a headless API. @REJECTED A tiny inline panel — rejected because live monitoring, evidence review, human checkpoint, and comparison each need dedicated surfaces; the run is a primary workflow, not a widget. @REJECTED Naming this "Verification history" — rejected because 037 VerificationRun is release-pipeline verification; Scenario runs must be labeled distinctly. +@RATIONALE After the 050 MCP drift, RUNMON-FR-011 "Investigate with agent" transitions into an external MCP client session over the same InvestigationCase; the monitor keeps rendering typed events only. ## Navigation (DSA Indexer keywords) @SEMANTICS: spec, requirements, feature, ux, scenario, run, monitor, result, history, compare @@ -161,4 +162,8 @@ Monitor model, timeline, checkpoint/result/history/compare components and scenar production signals into the queue. - `[ ]` Current browser, reconnect and accessibility evidence has not been retained. +## Drift Amendment — MCP Interface (2026-08-24) + +- The monitor remains a pure web surface over typed events. Its "Investigate with agent" button targets a HandoffSurface (connection hint + case context) instead of an in-product chat route. + #endregion ScenarioRunMonitor.Spec diff --git a/specs/046-dashboard-scenario-automation/spec.md b/specs/046-dashboard-scenario-automation/spec.md index fe4dcb348..e3ab01962 100644 --- a/specs/046-dashboard-scenario-automation/spec.md +++ b/specs/046-dashboard-scenario-automation/spec.md @@ -8,6 +8,7 @@ @RATIONALE After manual runs, users want scheduled/triggered runs after each deploy, ETL, or release, plus notifications and retention. 037 already has trigger semantics for VerificationRun; 046 generalizes them for scenario runs. @REJECTED Reimplementing a separate scheduler — rejected; reuse the 037 trigger framework and existing APScheduler infrastructure. @REJECTED Writing scenario run results into the 037 baseline catalog or verification pipeline — rejected; scenario runs are a distinct entity (044). +@RATIONALE SCAUTO-FR-013 delegated schedule management is exercisable through MCP tools after the 050 drift; scheduler dispatch, eligibility and deduplication remain deterministic server behavior for every actor. ## Navigation (DSA Indexer keywords) @SEMANTICS: spec, requirements, feature, scenario, automation, schedule, trigger, notification, operations @@ -170,4 +171,8 @@ the automation workflow is not wired end-to-end. scheduled-PROD integration test is still required, alongside the remaining retention/runtime workflow tests, before feature closure. +## Drift Amendment — MCP Interface (2026-08-24) + +- Automation management UI stays; MCP decision tools may drive the same CRUD under SCAUTO-FR-013 policy. Signals never auto-start client activity (pull-only). + #endregion ScenarioAutomation.Spec diff --git a/specs/047-dashboard-scenario-analytics/spec.md b/specs/047-dashboard-scenario-analytics/spec.md index d076b3f9b..5254ca0fd 100644 --- a/specs/047-dashboard-scenario-analytics/spec.md +++ b/specs/047-dashboard-scenario-analytics/spec.md @@ -8,6 +8,7 @@ @RATIONALE 036 supplies durable agent work and 044 supplies immutable run evidence, but neither provides an analyst-controlled queue and long-lived investigation case. Health and failure identity remain deterministic inputs to that work. @REJECTED Ending at pass/fail/inconclusive — rejected because operational workflow begins there. @REJECTED Opening an agent chat for every failure — rejected because queue deduplication and analyst intent are needed to avoid noise. +@REJECTED Server-rendered case chat as the only conversation medium — superseded 2026-08-24: durable case threads continue in external MCP clients per `specs/050-mcp-interface/spec.md`; evidence snapshot, AgentAction timeline and linked runs remain server-owned records. ## Navigation (DSA Indexer keywords) @SEMANTICS: spec, requirements, feature, scenario, triage, flakiness, analytics, health, trend @@ -126,4 +127,8 @@ required evidence-led investigation workflow is not production-complete. - `[~]` Deterministic aggregation/UI primitives exist but need end-to-end history and signal-ingestion proof; T013 remains partial. +## Drift Amendment — MCP Interface (2026-08-24) + +- InvestigationCase chat (open T016 item) is re-scoped: the server persists evidence snapshot, hypotheses, tool timeline and linked runs; the conversational thread lives in the analyst's external MCP client. Queue ingestion and "no auto-start" semantics are unchanged. + #endregion ScenarioAnalytics.Spec diff --git a/specs/050-mcp-interface/spec.md b/specs/050-mcp-interface/spec.md new file mode 100644 index 000000000..91e965ce2 --- /dev/null +++ b/specs/050-mcp-interface/spec.md @@ -0,0 +1,240 @@ +#region McpInterface.Spec [C:3] [TYPE ADR] [SEMANTICS mcp,interface,tools,agent,dashboard-testing,decommission] +@BRIEF Simple MCP interface to ss-tools that replaces the Gradio chat agent as the primary agentic surface, including full dashboard-test creation. +@RELATION DEPENDS_ON -> [Doc.Adr.ADR0001] +@RELATION DEPENDS_ON -> [Doc.Adr.ADR0005] +@RELATION DEPENDS_ON -> [Doc.Adr.ADR0006] +@RELATION DEPENDS_ON -> [AgentTestStabilization.Spec] +@RATIONALE ss-tools already concentrates truth and policy server-side: every agent tool is a thin forwarder over backend services (RBAC guard -> httpx -> truncated result), and 042-047 demand that any delegated actor works through the same server-owned contracts as an analyst. A conversational runtime coupled to a bespoke Gradio UI adds a parallel transport, a parallel HITL mechanism (LangGraph interrupt + confirmation module) and triple bookkeeping per capability (route + LangChain wrapper + allowlist). MCP externalizes reasoning to standard clients while the backend keeps deterministic validators, ActionApprovalGate, AgentAction provenance and InvestigationSignal semantics unchanged. +@REJECTED Keeping the Gradio chat agent long-term — rejected 2026-08-24: the chat surface duplicates policy transport, blocks external clients, and every new spec (042-047) multiplies hand-written wrappers. +@REJECTED Personal access tokens as the primary authentication mechanism — deferred 2026-08-24 in favor of full OAuth 2.1 (authorization code + PKCE) so that standard clients connect through discovery without manual token management; static tokens MAY return later as a convenience feature. +@REJECTED Embedding roles or permissions into access tokens — rejected because rights must be revocable in real time; tokens carry identity only and permissions resolve live from the database. +@REJECTED Adopting the vendored `research/mcp-superset` server as the tool surface — rejected because it talks to Superset directly, bypassing the ss-tools policy layer; direct SQL and raw Superset mutations violate 037 chart-truth boundaries and 038/044 executor contracts. It remains a reference implementation only. +@REJECTED Replacing 37 tools with one generic `api_call` tool — rejected because it destroys curated schemas/discoverability, degrades small-model tool selection, and is not MCP. +@REJECTED Auto-generating the tool catalog from the OpenAPI dump — rejected because curated descriptions, bounded response discipline and per-tool risk semantics are part of the safety contract; generation is allowed only as a scaffold for explicitly reviewed registrations. + +## Navigation (DSA Indexer keywords) +@SEMANTICS: spec, requirements, mcp, interface, tools, catalog, approval, provenance, decommission, gradio + +**Feature Branch**: `050-mcp-interface` +**Created**: 2026-08-24 | **Status**: Ready for Implementation +**Input**: "Убрать веб-интерфейс агента (Gradio, кнопка «Ассистент» и другие точки входа) и заменить его простым MCP-интерфейсом к ss-tools, через который внешние клиенты создают все тесты. Полноценный внешний доступ; первый срез — паритет текущих 37 инструментов; кнопка «Создать сценарий тестирования» выполняет handoff во внешний клиент." + +## Decisions (session 2026-08-24) + +- **Direction**: remove the Gradio agent (`agent/` service, port 7860, `/agent` route, assistant drawer/API); its place is taken by ONE simple MCP server mounted in the FastAPI backend. +- **Hosting**: `/mcp` inside the existing FastAPI app (FastMCP / official SDK, Streamable HTTP). No separate process, port or auth stack; tools call services in-process. +- **External access**: full, via OAuth 2.1 from Phase 0 — the ss-tools backend acts as the Authorization Server (authorization code + PKCE on top of the existing session login incl. ADFS OIDC), the MCP endpoint acts as a Resource Server. Dynamic client registration for public clients; `client_credentials`/`SERVICE_JWT` fallback for machine clients. +- **First slice**: parity with the current 37 LangChain `@tool` wrappers before any new capability lands exclusively on MCP. +- **Dashboard entry**: «Создать сценарий тестирования» becomes an explicit handoff to an external MCP client (instruction page / copyable prompt + connection parameters), not a chat launch. + +## Authorization Model + +The backend already owns the canonical RBAC shape (`Role(is_admin)` → `Permission(resource, action)`, pure predicate `user_has_permission()` in `core/auth/permission_utils.py`) and ships `Authlib` + `cryptography`. The drift reuses both; no parallel policy vocabulary is created. + +### Roles and endpoints + +| Part | Role | Endpoints / behavior | +|---|---|---| +| ss-tools backend | OAuth 2.1 Authorization Server | `/oauth/authorize` (requires existing web session: local password or ADFS OIDC), `/oauth/token` (`authorization_code` + PKCE S256, `refresh_token`, `client_credentials`), AS metadata (RFC 8414), JWKS | +| `/mcp` | OAuth 2.1 Resource Server | Protected-resource metadata (RFC 9728), `401` + `WWW-Authenticate` discovery hint, token validation per request | +| External client | Public/confidential OAuth client | Discovers AS via RFC 9728 → registers via DCR (RFC 7591) → authorization code + PKCE in the user's browser session → calls tools | + +### Token and identity rules + +- Access tokens are short-lived signed JWTs (`aud=mcp`) carrying identity only (`sub`, `scope`, `jti`, `session_id`). Roles/permissions are NEVER embedded; they resolve live from the DB per request, so role changes take effect immediately. +- Refresh tokens rotate on every use; reuse detection revokes the token family (extends the existing `TokenBlacklist` mechanism). +- Machine clients authenticate with `client_credentials` or the existing `SERVICE_JWT`; both map to a service principal with its own scope set — never to a user's roles. +- Authentication is a swappable adapter `resolve_principal(credentials) -> McpSessionPrincipal`; the tool catalog never sees transport credentials. + +### Tool catalog binding (hidden vs gated) + +1. Every tool registration declares `required_permission=("resource","action")` from the canonical vocabulary already used by specs (`scenario:view/create/edit/archive`, `dataset:lineage:refresh`, `dashboard:loadtest:*`, …). This is the single source of truth; the agent-side `_TOOL_PERMISSIONS` shadow is absorbed and deleted. +2. `tools/list` filters the catalog through `user_has_permission(principal.user, resource, action)`; `is_admin=True` sees everything. A tool the principal has no right for is absent from the listing (HIDDEN). +3. Every invocation re-checks the same predicate plus service-level policies (defense in depth against stale client catalogs). +4. Contextual risk does NOT hide a tool: a visible tool whose current invocation is risky (PROD environment, baseline publish) returns `approval_required{gate_id,...}` instead of failing silently (GATED). + +### Gates live entirely inside MCP + +The MCP session MUST be self-sufficient for the whole approval loop: a principal operating only through MCP clients completes creation, gating, running and investigation without opening the ss-tools web UI. Rule set: + +- Gated invocations return `approval_required{gate_id, targets, risk, reason_required, expires_at}` synchronously — the client resolves it in-session. +- `list_pending_approvals(filter?)` returns all gates visible to the principal (including gates raised by automation or other surfaces), each with target diff/provenance refs. +- `decide_approval(gate_id, confirm|deny, reason)` consumes the gate atomically (CAS), records the reason, and unblocks the original workflow deterministically; the caller may then retry or consume per the workflow contract. Expired/already-decided gates return typed errors, never silent success. +- Decision tools require an authenticated USER principal; service principals can list but never decide. Reasons are mandatory for confirm on high-risk classes (baseline publish, PROD dispatch, policy change) per AGSTAB-FR-005. +- There is exactly ONE durable gate store (ActionApprovalGate). Web gate cards (042/043/045 product pages) and MCP decision tools are two renderers of the same rows — neither creates parallel state, and a decision through either path is immediately visible to the other. + +### Human checkpoints through MCP + +HumanCheckpoint disposition (`waiting_human` runs, 044) is also completable inside MCP, amending SCEX-FR-013 for the post-chat world: + +- `list_checkpoints(filter?)` exposes waiting checkpoints visible to the principal with their evidence context; `decide_checkpoint(run_id, disposition=confirm|false_positive|inconclusive, expected_version)` consumes the checkpoint via the same CAS contract as the monitor. +- Guards preserve the real invariant (no automated false PASS): decision tools accept USER principals only, CAS version mismatch is a typed error, every decision is audited with principal+reason, and no scheduled/deploy/API origin can ever reach these tools. A service principal calling them gets `permission_denied`. +- The web monitor (045) remains an equal renderer of the same checkpoint rows; dispositions from either surface are immediately visible in both. + +## User Scenarios + +### Story 1 — Connect an External Client (P1) + +**Why P1**: The MCP endpoint is the product surface; connecting must be boring and secure. + +**Independent Test**: Point MCP Inspector or Claude Desktop at `{backend}/mcp` with a personal token and verify `tools/list` returns exactly the role-permitted catalog. + +**Acceptance**: +1. **Given** a valid user JWT **When** the client connects and lists tools **Then** the catalog contains only tools permitted for that user's role, with curated descriptions and JSON schemas. +2. **Given** an invalid/expired token **When** the client connects **Then** it receives a typed authentication error and zero tool metadata beyond the public surface. +3. **Given** a service JWT **When** a machine client lists tools **Then** machine-scoped tools resolve per service policy, indistinguishable in contract shape from user sessions. + +--- + +### Story 2 — Create a Dashboard Test Scenario End-to-End (P1) + +**Why P1**: "Создавать все тесты через MCP" is the core value replacing the chat flow. + +**Independent Test**: From an external client, drive inspect → compile → validate → resolve → draft-pack → save for a fixture dashboard and verify the saved immutable revision appears in the 042 registry. + +**Acceptance**: +1. **Given** a dashboard id and environment **When** the client calls the inspection and scenario-authoring tools **Then** the chain produces a validated graph with `needs_context`/`needs_selector` markers instead of invented data (038 semantics). +2. **Given** delegated policy permits the actor **When** save is requested **Then** a server-stored WorkingDraft produces a new immutable revision with full provenance; the client never uploads a full graph for save (SCEDIT-FR-009 inheritance). +3. **Given** policy does not delegate the save **When** save is requested **Then** the tool returns a typed `approval_required` envelope and nothing persists until the gate resolves. + +--- + +### Story 3 — Approval Round-Trip Fully Inside the Client (P1) + +**Why P1**: HITL must survive the chat removal WITHOUT forcing a context switch to the web UI; the MCP session is self-sufficient. + +**Independent Test**: Trigger a gated baseline-approval action via MCP, approve it via `decide_approval` in the same client session, and verify the subsequent consume succeeds; repeat with deny and with expiry. + +**Acceptance**: +1. **Given** a gated action **When** invoked via MCP **Then** the result is `approval_required{gate_id, targets, risk, reason_required, expires_at}` and no side effect occurs. +2. **Given** the analyst calls `list_pending_approvals` in the same or a later session **When** gates exist (including gates raised by automation) **Then** they are listed with target diff/provenance refs. +3. **Given** `decide_approval(gate_id, confirm|deny, reason)` **When** submitted by a user principal **Then** the gate is consumed atomically, the reason is recorded, and the original workflow continues deterministically (retry/consume). +4. **Given** a denial, expiry, or a replayed decision **When** any path retries **Then** the gate refuses with the recorded outcome; no duplicate side effect. +5. **Given** the same gate row **When** rendered as a web gate card OR decided via MCP from another session **Then** both surfaces show identical state — one durable store, two renderers. + +--- + +### Story 4 — Baseline and Verification Lifecycle via MCP (P2) + +**Why P2**: 037 capture/approval/verification flows were the first agent tools and must reach parity first. + +**Independent Test**: Execute capture_baseline_candidate → request_baseline_approval → decide → consume and create_verification_run via MCP against fixtures, matching legacy wrapper outputs. + +**Acceptance**: +1. **Given** fixture release/dashboard/metric inputs **When** capture runs **Then** the server computes hashes and creates the candidate exactly as the HTTP route does (no client hash logic). +2. **Given** the full approval lifecycle **When** driven via MCP **Then** outcomes are byte-comparable with the legacy tool results on the same fixtures. + +--- + +### Story 5 — Decommission the Chat Surface (P2) + +**Why P2**: The drift ends with the Gradio stack gone, not duplicated. + +**Independent Test**: Flip the removal flag, rebuild frontend and backend, and verify no `/agent` references, no agent process, and functional handoff entry points remain. + +**Acceptance**: +1. **Given** parity is proven **When** the flag removes the chat **Then** `run.sh`/compose no longer start the agent service; port 7860 disappears from profiles. +2. **Given** the dashboard page **When** the user clicks «Создать сценарий тестирования» **Then** a handoff surface opens (connection instructions + copyable prompt with dashboard context parameters), never a dead link. +3. **Given** the frontend build **When** link-integrity tests run **Then** zero references to `/agent`, assistant API or Gradio proxy remain. + +--- + +### Edge & Failure Cases + +| # | Scenario | Expected Behavior | Recovery | +|---|----------|-------------------|----------| +| E1 | JWT expires mid-session | Typed auth error on next call; no privileged retry | Client reconnects with fresh token | +| E2 | Stale cached tools/list after upgrade | Unknown tool call returns typed `TOOL_NOT_FOUND` + re-list hint | Client refreshes catalog | +| E3 | Oversized tool response | Bounded payload; large artifacts returned as ref + digest, never raw dumps | Client fetches artifact by ref | +| E4 | Concurrent scenario saves | 409 revision conflict with current hash (042 semantics) | Reload / compare | +| E5 | SQL-class tool requested in restricted context | Server-side denial regardless of client claims | Elevated permission required | +| E6 | Rate/abuse from external client | Standard throttling honored (`Retry-After`) | Back off | +| E7 | Client attempts raw graph upload to save | Rejected; save only from server-stored draft | Recompile server-side | +| E8 | Rotated refresh token replayed | Whole token family revoked; client re-authorizes | Re-run consent flow | +| E9 | ADFS unavailable at authorize time | Local-password session login still completes the flow | Retry SSO later | +| E10 | Role changed after tools/list cached by client | Invocation re-check denies with typed permission error + re-list hint | Client refreshes catalog | + +## Requirements + +### Functional + +- **MCPX-FR-001**: The system MUST expose exactly one MCP server at `{backend}/mcp` (Streamable HTTP) inside the FastAPI application; stdio transport MAY exist for local development only. +- **MCPX-FR-002**: Tools MUST be registered explicitly with curated name, description and input schema; OpenAPI-derived bulk generation MUST NOT ship. +- **MCPX-FR-003**: The `/mcp` endpoint MUST authenticate every request through the Authorization Model above; `tools/list` MUST filter by caller RBAC and every invocation MUST re-enforce permissions server-side (single server-side policy source; the agent-side `_tool_filter`/`_guard_tool_permission` logic is absorbed here). +- **MCPX-FR-004**: Every tool invocation MUST persist AgentAction provenance (principal, tool, arguments digest, outcome, linked AgentRun where applicable) and MUST pass deterministic ACL/environment/capacity checks before execution, inheriting AGSTAB-FR-011. +- **MCPX-FR-005**: Non-delegated risky actions MUST return the typed `approval_required` envelope bound to a durable ActionApprovalGate. The approval loop MUST be completable entirely within MCP (`list_pending_approvals` + `decide_approval`); web gate cards (042/043/045 product pages) remain an equal renderer of the same durable gates. Denial MUST record cancellation with zero side effects; decision tools MUST reject service principals and expired/decided gates with typed errors. +- **MCPX-FR-006**: The scenario-authoring chain (inspect query model, compile, validate, resolve, draft-pack, request-save) MUST be fully available via MCP; all mutable state MUST live server-side (WorkingDraft, revisions, candidates). +- **MCPX-FR-007**: Tool responses MUST be bounded; artifacts larger than the inline limit MUST be returned as typed refs with digests resolvable through existing artifact APIs. +- **MCPX-FR-008**: The initial catalog MUST provide 1:1 parity with the current 37 tools across domains: environments/health/tasks, git/deploy/migration/backup/maintenance, Superset operations (read + admin CRUD), baseline capture/approval/verification, scenario compile/validate/resolve/draft-pack/save. `show_capabilities` is retired in favor of standard `tools/list`. +- **MCPX-FR-009**: Decommission MUST be flag-driven and ordered: parity proven → chat hidden → agent service dropped from `run.sh`/compose → code deletion. Frontend entry points MUST switch to the handoff surface in the same phase that hides `/agent`. +- **MCPX-FR-010**: The catalog MUST carry a version; breaking changes (rename/schema change/removal) MUST bump the major version and remain listed with a deprecation marker for one minor cycle. +- **MCPX-FR-011**: MCP tools MUST reuse existing services and contracts; a second Playwright/LLM/SQL/execution stack is forbidden (SCEX-FR-009 inheritance). No MCP path may bypass 038 validation, 037 baseline rules, 044 executors or a required gate. +- **MCPX-FR-012**: InvestigationSignals and queue items MUST NOT trigger any MCP activity automatically; MCP is pull-only from the client side (SCAN-FR-001 inheritance). +- **MCPX-FR-013**: The backend MUST implement the Authorization Server endpoints of the Authorization Model table; PKCE S256 MUST be mandatory for public clients, and `/oauth/authorize` MUST reuse the existing web session (local password or ADFS OIDC) without a second credential prompt inside an active session. +- **MCPX-FR-014**: The MCP endpoint MUST publish RFC 9728 protected-resource metadata and answer unauthenticated calls with `401` + `WWW-Authenticate` so a compliant client completes discovery → registration → authorization → tool listing without manual token pasting. +- **MCPX-FR-015**: Dynamic client registration (RFC 7591) MUST be supported for public clients with first-party-bounded scopes; registered clients MUST be visible and revocable in Admin. +- **MCPX-FR-016**: Refresh tokens MUST rotate on use, and replay of a rotated refresh token MUST revoke the whole token family (extending `TokenBlacklist`). +- **MCPX-FR-017**: Access tokens MUST carry identity only (`sub`, `scope`, `jti`, `session_id`, `aud=mcp`); permission resolution MUST hit live DB state per request so role changes apply to the next call without re-consent. +- **MCPX-FR-018**: Catalog visibility MUST follow the hidden-vs-gated rule: missing permission hides the tool from `tools/list`; contextual risk (PROD environment, baseline publish, non-delegated mutation) keeps the tool listed but returns `approval_required`. +- **MCPX-FR-019**: HumanCheckpoint disposition MUST be available via `list_checkpoints` + `decide_checkpoint` under the same CAS/audit contract as the 045 monitor, restricted to authenticated user principals; no automated origin may create, consume or bypass a checkpoint, and manual-run-only revisions remain ineligible for automation (SCEX-FR-004a stands). + +### Key Entities + +- **McpToolCatalog**: Versioned, explicitly registered set of tools grouped by domain; source of truth for names, schemas, risk class and `required_permission(resource, action)`. +- **McpSessionPrincipal**: Authenticated caller identity (user or service), role set resolved live from DB per invocation, and delegation scope. +- **OAuthClientRecord**: Dynamically or statically registered client (public/confidential), first-party scope bound, owner visibility, revocation state. +- **ApprovalRequiredEnvelope**: Typed result binding a refused-or-deferred action to its durable gate (gate_id, targets, risk, reason_required). +- **ToolInvocationRecord**: AgentAction-provenance row per MCP tool call (principal, tool, argument digest, outcome, run linkage). +- **HandoffSurface**: Frontend instruction/copy affordance replacing the «Создать сценарий тестирования» chat launch (dashboard context parameters + connection hint). + +## Success Criteria + +- **SC-001**: An external client completes the full scenario creation chain for a fixture dashboard and the revision appears in the registry with correct provenance. +- **SC-002**: 100% of parity tests pass: MCP tool outcomes match legacy `@tool` wrapper outputs on shared fixtures. +- **SC-003**: 100% of gated invocations produce zero side effects before approval; denial/expiry paths refuse cleanly. +- **SC-004**: `tools/list` is RBAC-exact for admin/analyst/viewer roles in fixture tests. +- **SC-005**: After decommission, builds and link-integrity suites pass with zero `/agent`, assistant-API or Gradio-proxy references; the stack starts without port 7860. +- **SC-006**: No catalog path reaches arbitrary SQL or raw Superset mutation outside the governed tools; scenario contexts cannot invoke SQL-class tools. +- **SC-007**: A compliant external client completes discovery (RFC 9728) → DCR → authorization code + PKCE → `tools/list` in one automated flow, with zero manual token management. +- **SC-008**: Refresh-token replay revokes the token family in 100% of fault-injection cases; pre-revocation access tokens die at expiry, not silently extended. +- **SC-009**: A role change is reflected in the next `tools/list` and the next invocation without new consent; a revoked permission hides the tool and denies cached-catalog calls. + +## Clarifications + +### Session 2026-08-24 + +- Q: Separate MCP process or mounted in backend? → A: Mounted in the FastAPI app; simplest deployment, in-process service reuse, shared auth middleware. +- Q: Does removing chat remove HITL? → A: No, and it does not force the web UI either (session 2026-08-24): gates are server-durable and fully resolvable inside MCP (`list_pending_approvals` + `decide_approval`); web gate cards on product pages remain an equal renderer of the same rows for browser-first users. +- Q: Is the vendored mcp-superset adopted? → A: No — reference only; it bypasses the policy layer. +- Q: What happens to AgentRun/DraftArtifact/InvestigationSignal contracts? → A: They persist unchanged; MCP invocations create the same provenance rows. Only the conversational transport and its UI retire. +- Q: OAuth now or later? → A: Now (session 2026-08-24 decision). The backend becomes a full OAuth 2.1 Authorization Server in Phase 0 — `Authlib==1.6.6` is already a dependency; personal access tokens are explicitly deferred, not rejected forever. +- Q: Where do rights live? → A: In the existing DB RBAC (`Role`/`Permission`, `user_has_permission`). Tokens never embed roles; the catalog binds tools to canonical `resource:action` pairs and filters both listing and invocation through the same predicate. + +## Phases + +| Phase | Scope | Exit evidence | +|---|---|---| +| 0 | OAuth AS+RS skeleton: authorize/token/DCR/JWKS/metadata endpoints, `/mcp` validation middleware, 2–3 probe tools, Inspector + scripted-client connectivity | SC-007 green in CI | +| 1 | Parity catalog for the 37 tools + contract tests mirroring wrapper tests; permission-bound hidden/gated matrix | SC-002, SC-004, SC-009 | +| 2 | Gates/provenance over MCP; end-to-end scenario creation walkthrough; refresh rotation/reuse tests | SC-001, SC-003, SC-008 | +| 3 | Frontend decommission behind flag; handoff surface; assistant API retirement | SC-005 partial | +| 4 | Delete `agent/` service; `run.sh`/compose updates; spec amortization closed | SC-005 full | + +## Spec Impact & Amortization Map + +| Spec | Amendment | +|---|---| +| 036 | Transport rejection recorded; durable-runtime contracts carry over to MCP provenance | +| 037 | Baseline tools join the MCP catalog unchanged (thin forwarders) | +| 038 | Authoring entry becomes MCP-callable; validator remains sole gateway | +| 039 | AGUI-FR-001..013 chat workspace superseded; entry action becomes HandoffSurface | +| 040 | Delegated load experiments readable as MCP-driven, policy unchanged | +| 041 | Blast-radius explanation consumed by external clients; index contracts unchanged | +| 042 | Delegated actors explicitly include MCP principals; registry contracts unchanged | +| 043 | "Edit with agent" proposals callable via MCP; SCEDIT-FR-009 stands | +| 044 | Runner unaffected; authoring/investigation boundary inherited by MCP clients | +| 045 | "Investigate with agent" targets an external client session | +| 046 | Schedule management exercisable through MCP tools under same policy | +| 047 | Case threads continue in external clients; server keeps evidence/timeline | + +#endregion McpInterface.Spec diff --git a/specs/050-mcp-interface/tasks.md b/specs/050-mcp-interface/tasks.md new file mode 100644 index 000000000..31e0c2899 --- /dev/null +++ b/specs/050-mcp-interface/tasks.md @@ -0,0 +1,49 @@ +# 050-mcp-interface — Tasks + +> Правило: `[ ]` не начата; `[~]` в работе; `[x]` только с доказательством (команда + вывод). + +## Phase 0 — OAuth skeleton + `/mcp` probe + +- [ ] T001 Зависимость MCP SDK/FastMCP в `backend/requirements.txt`; каркас `backend/src/mcp_server/` с монтированием `/mcp` в FastAPI app. +- [ ] T002 Authorization Server на Authlib: `/oauth/authorize` (поверх существующей web-сессии: local password + ADFS OIDC), JWKS, AS metadata (RFC 8414). +- [ ] T003 `/oauth/token`: authorization_code + PKCE S256 (обязателен для публичных клиентов), refresh_token с ротацией, client_credentials для service-principal. +- [ ] T004 DCR (RFC 7591): регистрация публичных клиентов с first-party scope bound; список/отзыв в Admin. +- [ ] T005 Resource Server: RFC 9728 metadata для `/mcp`, `401 + WWW-Authenticate`, валидация access-JWT (`aud=mcp`, jti/blacklist) на каждый запрос; `resolve_principal()` адаптер. +- [ ] T006 Refresh rotation + reuse-detection: повтор ротированного refresh токена отзывaет семейство (расширение `TokenBlacklist`); fault-injection тесты. +- [ ] T007 2–3 пробных инструмента (read-only: list_environments, get_health_summary, search_dashboards) с явной регистрацией и курируемыми схемами. +- [ ] T008 CI: скриптованный клиент проходит discovery → DCR → PKCE → token → `tools/list` без ручных шагов (SC-007); подключение MCP Inspector; документация в README/INSTALL. + +## Phase 1 — Parity catalog (37 tools) + +- [ ] T010 Каталог-реестр инструментов по доменам: env/health/tasks, git/deploy/migration/backup/maintenance, superset ops, baseline, scenario. Каждый инструмент декларирует `required_permission(resource, action)` из канонического словаря; единая серверная политика поглощает `_tool_filter`. +- [ ] T011 Инструменты env/health/tasks (list_environments, get_health_summary, get_task_status, maintenance CRUD, llm status). +- [ ] T012 Инструменты git/deploy/migration/backup. +- [ ] T013 Superset-инструменты (databases/explore/sql/format/permissions/dashboard+dataset CRUD) — SQL-класс помечен risk-классом и отдельным правом. +- [ ] T014 Baseline-домен: capture_baseline_candidate, request/decide/consume_baseline_approval, create_verification_run (паритет fixtures с tools_037). +- [ ] T015 Scenario-домен: scenario_compile/validate/resolve/generate_draft_pack/request_save (паритет с tools_038; save только из server-stored draft). +- [ ] T016 Контрактные тесты паритета: выводы MCP-инструментов сопоставлены с legacy-обёртками на общих фикстурах (SC-002). +- [ ] T017 Bounded-response дисциплина: лимит инлайн-ответа, артефакты как ref+digest. +- [ ] T018 Hidden/gated матрица: admin/analyst/viewer × каталог — отсутствие права скрывает инструмент из `tools/list`; role-change виден на следующем вызове без re-consent (SC-004, SC-009). + +## Phase 2 — Gates & provenance over MCP + +- [ ] T020 AgentAction-provenance на каждый вызов (ToolInvocationRecord): principal, tool, digest аргументов, исход, связь с AgentRun. +- [ ] T021 `approval_required` конверт для негelegированных действий; durable ActionApprovalGate без side effects до решения (SC-003). +- [ ] T022 Gate-инструменты как первичный путь полного цикла в клиенте: `list_pending_approvals(filter)` (+гейты от автоматизации) и `decide_approval(gate_id, confirm|deny, reason)` с CAS, обязательным reason для high-risk confirm, отказом service-principals и typed-ошибками на expired/replayed; web gate cards — равноправный рендер тех же строк (SC-003). +- [ ] T023 E2E-walkthrough: внешний клиент создаёт сценарий фикстурного дашборда end-to-end → revision в registry (SC-001). +- [ ] T024 Gated-вызовы по контексту: PROD-окружение и baseline publish возвращают `approval_required` при видимом инструменте (hidden-vs-gated, MCPX-FR-018). +- [ ] T025 Checkpoint-инструменты: `list_checkpoints` + `decide_checkpoint(run_id, disposition, expected_version)` через тот же CAS/аудит, что и монитор 045; user-principal only (service → permission_denied); тест что автоматизация не имеет пути к чекпоинтам (MCPX-FR-019). + +## Phase 3 — Frontend decommission (flag-driven) + +- [ ] T030 HandoffSurface: страница-инструкция + копируемый промпт с контекстом дашборда; кнопка «Создать сценарий тестирования» переключается на handoff. +- [ ] T031 Скрыть `/agent`, AssistantChatPanel, кнопку «Ассистент» в TopNavbar за флагом; обновить link-integrity тесты. +- [ ] T032 Retire `/api/assistant/*` и прокси `/api/agent/gradio` за флагом; retention-настройки assistant скрыть. +- [ ] T033 vitest/build/link-integrity зелёные при включённом флаге демонтажа. + +## Phase 4 — Removal + +- [ ] T040 Удалить сервис `agent/` из run.sh и compose-профилей (порт 7860); обновить AGENTS.md/INSTALL.md. +- [ ] T041 Удалить код чата: agent/src (app, langgraph_setup, tools*.py, _confirmation, middleware...), frontend agent/assistant компоненты, i18n, типы. +- [ ] T042 Финальные правки спек 036–047: перенести drift-amendments из статуса «planned» в «done» со ссылками на доказательства. +- [ ] T043 Полный прогон backend/frontend suites + стенд без 7860 (SC-005).