TenantAtlas/app/Support/Ui/OperatorExplanation/OperatorExplanationBuilder.php
2026-03-24 12:23:07 +01:00

246 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Ui\OperatorExplanation;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
final class OperatorExplanationBuilder
{
/**
* @param array<int, CountDescriptor> $countDescriptors
*/
public function build(
ExplanationFamily $family,
string $headline,
string $executionOutcome,
string $executionOutcomeLabel,
string $evaluationResult,
TrustworthinessLevel $trustworthinessLevel,
string $reliabilityStatement,
?string $coverageStatement,
?string $dominantCauseCode,
?string $dominantCauseLabel,
?string $dominantCauseExplanation,
string $nextActionCategory,
string $nextActionText,
array $countDescriptors = [],
bool $diagnosticsAvailable = false,
?string $diagnosticsSummary = null,
): OperatorExplanationPattern {
return new OperatorExplanationPattern(
family: $family,
headline: $headline,
executionOutcome: $executionOutcome,
executionOutcomeLabel: $executionOutcomeLabel,
evaluationResult: $evaluationResult,
trustworthinessLevel: $trustworthinessLevel,
reliabilityStatement: $reliabilityStatement,
coverageStatement: $coverageStatement,
dominantCauseCode: $dominantCauseCode,
dominantCauseLabel: $dominantCauseLabel,
dominantCauseExplanation: $dominantCauseExplanation,
nextActionCategory: $nextActionCategory,
nextActionText: $nextActionText,
countDescriptors: $countDescriptors,
diagnosticsAvailable: $diagnosticsAvailable,
diagnosticsSummary: $diagnosticsSummary,
);
}
/**
* @param array<int, CountDescriptor> $countDescriptors
*/
public function fromArtifactTruthEnvelope(
ArtifactTruthEnvelope $truth,
array $countDescriptors = [],
): OperatorExplanationPattern {
$reason = $truth->reason?->toReasonResolutionEnvelope();
$family = $this->familyForTruth($truth, $reason);
$trustworthiness = $this->trustworthinessForTruth($truth, $reason);
$evaluationResult = $this->evaluationResultForTruth($truth, $family);
$executionOutcome = $this->executionOutcomeKey($truth->executionOutcome);
$executionOutcomeLabel = $this->executionOutcomeLabel($truth->executionOutcome);
$dominantCauseCode = $reason?->internalCode;
$dominantCauseLabel = $reason?->operatorLabel ?? $truth->primaryLabel;
$dominantCauseExplanation = $reason?->shortExplanation ?? $truth->primaryExplanation;
$headline = $this->headlineForTruth($truth, $family, $trustworthiness);
$reliabilityStatement = $this->reliabilityStatementForTruth($truth, $trustworthiness);
$coverageStatement = $this->coverageStatementForTruth($truth, $reason);
$nextActionText = $truth->nextStepText();
$nextActionCategory = $this->nextActionCategory($truth->actionability, $reason);
$diagnosticsAvailable = $truth->reason !== null
|| $truth->diagnosticLabel !== null
|| $countDescriptors !== [];
return $this->build(
family: $family,
headline: $headline,
executionOutcome: $executionOutcome,
executionOutcomeLabel: $executionOutcomeLabel,
evaluationResult: $evaluationResult,
trustworthinessLevel: $trustworthiness,
reliabilityStatement: $reliabilityStatement,
coverageStatement: $coverageStatement,
dominantCauseCode: $dominantCauseCode,
dominantCauseLabel: $dominantCauseLabel,
dominantCauseExplanation: $dominantCauseExplanation,
nextActionCategory: $nextActionCategory,
nextActionText: $nextActionText,
countDescriptors: $countDescriptors,
diagnosticsAvailable: $diagnosticsAvailable,
diagnosticsSummary: $diagnosticsAvailable
? 'Technical truth detail remains available below the primary explanation.'
: null,
);
}
private function familyForTruth(
ArtifactTruthEnvelope $truth,
?ReasonResolutionEnvelope $reason,
): ExplanationFamily {
return match (true) {
$reason?->absencePattern === 'suppressed_output' => ExplanationFamily::SuppressedOutput,
$reason?->absencePattern === 'blocked_prerequisite' => ExplanationFamily::BlockedPrerequisite,
$truth->executionOutcome === 'pending' || $truth->artifactExistence === 'not_created' && $truth->actionability !== 'required' => ExplanationFamily::InProgress,
$truth->executionOutcome === 'failed' || $truth->executionOutcome === 'blocked' => ExplanationFamily::BlockedPrerequisite,
$truth->artifactExistence === 'created_but_not_usable' || $truth->contentState === 'missing_input' => ExplanationFamily::MissingInput,
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' && $truth->primaryLabel === 'Trustworthy artifact' => ExplanationFamily::TrustworthyResult,
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' => ExplanationFamily::NoIssuesDetected,
$truth->artifactExistence === 'historical_only' => ExplanationFamily::Unavailable,
default => ExplanationFamily::CompletedButLimited,
};
}
private function trustworthinessForTruth(
ArtifactTruthEnvelope $truth,
?ReasonResolutionEnvelope $reason,
): TrustworthinessLevel {
if ($reason?->trustImpact !== null) {
return TrustworthinessLevel::tryFrom($reason->trustImpact) ?? TrustworthinessLevel::LimitedConfidence;
}
return match (true) {
$truth->artifactExistence === 'created_but_not_usable',
$truth->contentState === 'missing_input',
$truth->executionOutcome === 'failed',
$truth->executionOutcome === 'blocked' => TrustworthinessLevel::Unusable,
$truth->supportState === 'limited_support',
in_array($truth->contentState, ['reference_only', 'unsupported'], true) => TrustworthinessLevel::DiagnosticOnly,
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' && $truth->actionability === 'none' => TrustworthinessLevel::Trustworthy,
default => TrustworthinessLevel::LimitedConfidence,
};
}
private function evaluationResultForTruth(
ArtifactTruthEnvelope $truth,
ExplanationFamily $family,
): string {
return match ($family) {
ExplanationFamily::TrustworthyResult => 'full_result',
ExplanationFamily::NoIssuesDetected => 'no_result',
ExplanationFamily::SuppressedOutput => 'suppressed_result',
ExplanationFamily::MissingInput,
ExplanationFamily::BlockedPrerequisite,
ExplanationFamily::Unavailable => 'unavailable',
ExplanationFamily::InProgress => 'unavailable',
ExplanationFamily::CompletedButLimited => 'incomplete_result',
};
}
private function executionOutcomeKey(?string $executionOutcome): string
{
$normalized = BadgeCatalog::normalizeState($executionOutcome);
return match ($normalized) {
'queued', 'running', 'pending' => 'in_progress',
'partially_succeeded' => 'completed_with_follow_up',
'blocked' => 'blocked',
'failed' => 'failed',
default => 'completed',
};
}
private function executionOutcomeLabel(?string $executionOutcome): string
{
if (! is_string($executionOutcome) || trim($executionOutcome) === '') {
return 'Completed';
}
$spec = BadgeCatalog::spec(BadgeDomain::OperationRunOutcome, $executionOutcome);
return $spec->label !== 'Unknown' ? $spec->label : ucfirst(str_replace('_', ' ', trim($executionOutcome)));
}
private function headlineForTruth(
ArtifactTruthEnvelope $truth,
ExplanationFamily $family,
TrustworthinessLevel $trustworthiness,
): string {
return match ($family) {
ExplanationFamily::TrustworthyResult => 'The result is ready to use.',
ExplanationFamily::NoIssuesDetected => 'No follow-up was detected from this result.',
ExplanationFamily::SuppressedOutput => 'The run completed, but normal output was intentionally suppressed.',
ExplanationFamily::MissingInput => 'The result exists, but missing inputs keep it from being decision-grade.',
ExplanationFamily::BlockedPrerequisite => 'The workflow did not produce a usable result because a prerequisite blocked it.',
ExplanationFamily::InProgress => 'The result is still being prepared.',
ExplanationFamily::Unavailable => 'A result is not currently available for this surface.',
ExplanationFamily::CompletedButLimited => match ($trustworthiness) {
TrustworthinessLevel::DiagnosticOnly => 'The result is available for diagnostics, not for a final decision.',
TrustworthinessLevel::LimitedConfidence => 'The result is available, but it should be read with caution.',
TrustworthinessLevel::Unusable => 'The result is not reliable enough to use as-is.',
default => 'The result completed with operator follow-up.',
},
};
}
private function reliabilityStatementForTruth(
ArtifactTruthEnvelope $truth,
TrustworthinessLevel $trustworthiness,
): string {
return match ($trustworthiness) {
TrustworthinessLevel::Trustworthy => 'Trustworthiness is high for the intended operator task.',
TrustworthinessLevel::LimitedConfidence => $truth->primaryExplanation
?? 'Trustworthiness is limited because coverage, freshness, or publication readiness still need review.',
TrustworthinessLevel::DiagnosticOnly => 'This output is suitable for diagnostics only and should not be treated as the final answer.',
TrustworthinessLevel::Unusable => 'This output is not reliable enough to support the intended operator action yet.',
};
}
private function coverageStatementForTruth(
ArtifactTruthEnvelope $truth,
?ReasonResolutionEnvelope $reason,
): ?string {
return match (true) {
$truth->contentState === 'trusted' && $truth->freshnessState === 'current' => 'Coverage and artifact quality are sufficient for the default reading path.',
$truth->freshnessState === 'stale' => 'The artifact exists, but freshness limits how confidently it should be used.',
$truth->contentState === 'partial' => 'Coverage is incomplete, so the visible output should be treated as partial.',
$truth->contentState === 'missing_input' => $reason?->shortExplanation ?? 'Required inputs were missing or unusable when this result was assembled.',
in_array($truth->contentState, ['reference_only', 'unsupported'], true) => 'Only reduced-fidelity support is available for this result.',
$truth->publicationReadiness === 'blocked' => 'The artifact exists, but it is still blocked from the intended downstream use.',
default => null,
};
}
private function nextActionCategory(
string $actionability,
?ReasonResolutionEnvelope $reason,
): string {
if ($reason?->actionability === 'retryable_transient') {
return 'retry_later';
}
return match ($actionability) {
'none' => 'none',
'optional' => 'review_evidence_gaps',
default => $reason?->actionability === 'prerequisite_missing'
? 'fix_prerequisite'
: 'manual_validate',
};
}
}