Files
ss-tools/backend/src/services/reports/normalizer.py
busya 7bfc5553cf feat: live app log console, cross-filter, JSONL export
Backend:
- /ws/app-logs — real-time app/cot log stream (raw JSONL)
- /api/logs/recent — REST snapshot of ring buffer
- GET /tasks/{id}/logs/export — streaming JSONL export with CoT parse + redaction
- Thread-safe ring buffer (seq-based polling) replaces unsafe asyncio.Queue
- GIL-friendly multi-row Core insert for log persistence
- task_id ContextVar propagates into CotJsonFormatter for CoT correlation
- Hot-apply logging level on settings update (FR-005)
- Buffer trim under DEBUG floods; drop DEBUG first, preserve ERROR/WARNING
- List projection (include_result=False) keeps reports list slim
- Security event consolidated to single REASON atom

Frontend:
- ReportsLogModel + ReportsLogPanel — full live JSONL console
- Cross-filter pinning: Tasks → Logs tab with task chip badges
- LogEntryRow — CoT-aware rendering (marker icons, expandable payload)
- TaskFilterChip, taskChipMeta — scannable type/id/env chips
- Global drawer push via CSS variable (lg+ padding, not overlay)
- i18n en/ru for all log console strings
- Ctrl+A in log panel selects only log lines (window-level handler)

QA fixes:
- Svelte 5 reactivity: SvelteDate/SvelteSet/SvelteURLSearchParams
- Fix seed_trace_id shadowing (F823) in lifecycle.py
- Remove dead code (selectedEnvironment, goToReportsPage)
- Add @BRIEF to C2 test functions, missing {#each} keys
- Remove unused import json as _json from app.py

All 3200+ frontend tests pass; backend lint clean.
2026-07-20 21:41:34 +03:00

188 lines
8.4 KiB
Python

# #region normalizer [C:5] [TYPE Module] [SEMANTICS pydantic, report, task, normalize, status]
# @defgroup Services Module group.
# @BRIEF Convert task manager task objects into canonical unified TaskReport entities with deterministic fallback behavior.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [EXT:frontend:TaskModel]
# @RELATION DEPENDS_ON -> [EXT:frontend:ReportModel]
# @RELATION DEPENDS_ON -> [EXT:frontend:TypeProfiles]
# @INVARIANT Normalizer instance maintains consistent field order
# @DATA_CONTRACT ReportRow -> NormalizerInput; session_id -> valid UUID
# @PRE session is active and valid
# @POST Returns Normalizer output with normalized fields
# @SIDE_EFFECT Read-only database operations
from datetime import UTC, datetime
from typing import Any
from ...core.logger import belief_scope
from ...core.task_manager.models import Task, TaskStatus
from ...models.report import ErrorContext, ReportStatus, TaskReport
from .type_profiles import get_type_profile, resolve_task_type
# #region status_to_report_status [TYPE Function]
# @ingroup Services
# @BRIEF Normalize internal task status to canonical report status.
# @PRE status may be known or unknown string/enum value.
# @POST Always returns one of canonical ReportStatus values.
def status_to_report_status(status: Any) -> ReportStatus:
with belief_scope("status_to_report_status"):
raw = str(status.value if isinstance(status, TaskStatus) else status).upper()
if raw == TaskStatus.SUCCESS.value:
return ReportStatus.SUCCESS
if raw == TaskStatus.FAILED.value:
return ReportStatus.FAILED
if raw in {TaskStatus.PENDING.value, TaskStatus.RUNNING.value, TaskStatus.AWAITING_INPUT.value, TaskStatus.AWAITING_MAPPING.value}:
return ReportStatus.IN_PROGRESS
return ReportStatus.PARTIAL
# #endregion status_to_report_status
# #region build_summary [TYPE Function]
# @ingroup Services
# @BRIEF Build deterministic user-facing summary from task payload and status.
# @PRE report_status is canonical; plugin_id may be unknown.
# @POST Returns non-empty summary text.
def build_summary(task: Task, report_status: ReportStatus) -> str:
with belief_scope("build_summary"):
result = task.result
if isinstance(result, dict):
for key in ("summary", "message", "status_message", "description"):
value = result.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
if report_status == ReportStatus.SUCCESS:
return "Task completed successfully"
if report_status == ReportStatus.FAILED:
return "Task failed"
if report_status == ReportStatus.IN_PROGRESS:
return "Task is in progress"
return "Task completed with partial data"
# #endregion build_summary
# #region extract_error_context [TYPE Function]
# @ingroup Services
# @BRIEF Extract normalized error context and next actions for failed/partial reports.
# @PRE task is a valid Task object.
# @POST Returns ErrorContext for failed/partial when context exists; otherwise None.
def extract_error_context(task: Task, report_status: ReportStatus) -> ErrorContext | None:
with belief_scope("extract_error_context"):
if report_status not in {ReportStatus.FAILED, ReportStatus.PARTIAL}:
return None
result = task.result if isinstance(task.result, dict) else {}
message = None
code = None
next_actions = []
if isinstance(result.get("error"), dict):
error_obj = result.get("error", {})
message = error_obj.get("message") or message
code = error_obj.get("code") or code
actions = error_obj.get("next_actions")
if isinstance(actions, list):
next_actions = [str(action) for action in actions if str(action).strip()]
if not message:
message = result.get("error_message") if isinstance(result.get("error_message"), str) else None
if not message:
for log in reversed(task.logs):
if str(log.level).upper() == "ERROR" and log.message:
message = log.message
break
if not message:
message = "Not provided"
if not next_actions:
next_actions = ["Review task diagnostics", "Retry the operation"]
return ErrorContext(code=code, message=message, next_actions=next_actions)
# #endregion extract_error_context
# #region normalize_task_report [TYPE Function]
# @ingroup Services
# @BRIEF Convert one Task to canonical TaskReport envelope.
# @PRE task has valid id and plugin_id fields.
# @POST Returns TaskReport with required fields and deterministic fallback behavior.
# @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)
report_status = status_to_report_status(task.status)
# LLM validation paradox fix: if task completed successfully but validation found
# problems, override to PARTIAL (warning) so the UI doesn't flash green for failures.
if report_status == ReportStatus.SUCCESS and task.plugin_id == "llm_dashboard_validation":
result = task.result if isinstance(task.result, dict) else {}
fail_count = result.get("fail_count", 0)
warn_count = result.get("warn_count", 0)
raw_status = str(result.get("status", "")).upper()
if (isinstance(fail_count, int) and fail_count > 0) or raw_status == "FAIL":
report_status = ReportStatus.FAILED
elif (isinstance(warn_count, int) and warn_count > 0) or raw_status == "WARN":
report_status = ReportStatus.PARTIAL
profile = get_type_profile(task_type)
started_at = task.started_at if isinstance(task.started_at, datetime) else None
updated_at = task.finished_at if isinstance(task.finished_at, datetime) else None
if not updated_at:
updated_at = started_at or datetime.now(UTC)
# List projection: keep only lightweight profile metadata (~150 B).
# Full task.result (LLM logs, backup dashboards, …) belongs on detail only.
details: dict[str, Any] = {
"profile": {
"display_label": profile.get("display_label"),
"visual_variant": profile.get("visual_variant"),
"icon_token": profile.get("icon_token"),
"emphasis_rules": profile.get("emphasis_rules", []),
},
}
if include_result:
details["result"] = (
task.result if task.result is not None else {"note": "Not provided"}
)
source_ref: dict[str, Any] = {}
if isinstance(task.params, dict):
for key in ("environment_id", "source_env_id", "target_env_id", "dashboard_id", "dataset_id", "resource_id"):
if key in task.params:
source_ref[key] = task.params.get(key)
return TaskReport(
report_id=task.id,
task_id=task.id,
task_type=task_type,
status=report_status,
started_at=started_at,
updated_at=updated_at,
summary=build_summary(task, report_status),
details=details,
error_context=extract_error_context(task, report_status),
source_ref=source_ref or None,
)
# #endregion normalize_task_report
# #endregion normalizer