# #region Services.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 Services.Normalizer.StatusToReportStatus [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 Services.Normalizer.StatusToReportStatus # #region Services.Normalizer.BuildSummary [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 Services.Normalizer.BuildSummary # #region Services.Normalizer.ExtractErrorContext [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.intent: message = log.intent 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 Services.Normalizer.ExtractErrorContext # #region Services.Normalizer.NormalizeTaskReport [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 Services.Normalizer.NormalizeTaskReport # #endregion Services.Normalizer