188 lines
8.6 KiB
Python
188 lines
8.6 KiB
Python
# #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
|