package cli import ( "context" "errors" "fmt" "strconv" "strings" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/app" "gitea.maximumdirect.net/eric/weatherreporter/internal/comparison" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) const ( commandGenerate = "generate" commandRun = "run" commandCompare = "compare" summaryStatusSucceeded = "succeeded" summaryStatusFailed = "failed" ) type generateSummary struct { Command string `json:"command"` ReportID report.ID `json:"reportId"` ReportName string `json:"reportName"` PromptID string `json:"promptId"` RunID string `json:"runId"` Status string `json:"status"` GeneratedAt time.Time `json:"generatedAt"` ValidPeriod timeutil.Period `json:"validPeriod"` OutputPath string `json:"outputPath,omitempty"` LLMDebugPath string `json:"llmDebugPath,omitempty"` PromptVersion string `json:"promptVersion"` Timezone string `json:"timezone"` ProfileID string `json:"profileId,omitempty"` BackendID string `json:"backendId,omitempty"` ModelName string `json:"modelName,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` ValidationStatus string `json:"validationStatus,omitempty"` Notification *generateNotificationSummary `json:"notification,omitempty"` Error string `json:"error,omitempty"` } type generateNotificationSummary struct { Status string `json:"status,omitempty"` UploadStatus string `json:"uploadStatus,omitempty"` StatusError string `json:"statusError,omitempty"` RunID string `json:"runId,omitempty"` PipelineID string `json:"pipelineId,omitempty"` BundleID string `json:"bundleId,omitempty"` IdempotencyKey string `json:"idempotencyKey,omitempty"` AcceptedAt *time.Time `json:"acceptedAt,omitempty"` StartedAt *time.Time `json:"startedAt,omitempty"` FinishedAt *time.Time `json:"finishedAt,omitempty"` Error string `json:"error,omitempty"` } type batchSummary struct { Command string `json:"command"` Batch app.BatchKind `json:"batch"` Status string `json:"status"` StartedAt time.Time `json:"startedAt"` FinishedAt time.Time `json:"finishedAt"` Total int `json:"total"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` Notification *app.BatchNotificationResult `json:"notification,omitempty"` Reports []app.BatchReportResult `json:"reports"` Error string `json:"error,omitempty"` } type comparisonSummary struct { Command string `json:"command"` ComparisonID string `json:"comparisonId"` ReportID report.ID `json:"reportId"` ReportName string `json:"reportName"` PromptID string `json:"promptId"` PromptVersion string `json:"promptVersion"` PromptHash string `json:"promptHash"` Status string `json:"status"` StartedAt time.Time `json:"startedAt"` FinishedAt time.Time `json:"finishedAt"` Timezone string `json:"timezone"` ValidPeriod timeutil.Period `json:"validPeriod"` OutputDirectory string `json:"outputDirectory"` ManifestPath string `json:"manifestPath,omitempty"` DataPackagePath string `json:"dataPackagePath,omitempty"` Total int `json:"total"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` Results []comparisonProfileSummary `json:"results"` Error *comparison.SafeError `json:"error,omitempty"` } type comparisonProfileSummary struct { Position int `json:"position"` ProfileID string `json:"profileId"` BackendID string `json:"backendId,omitempty"` ModelName string `json:"modelName"` Status string `json:"status"` ValidationStatus string `json:"validationStatus,omitempty"` ReportPath string `json:"reportPath,omitempty"` LLMDebugPath string `json:"llmDebugPath,omitempty"` Error *comparison.SafeError `json:"error,omitempty"` } func newGenerateSummary(result *app.ReportResult, err error) generateSummary { summary := generateSummary{Command: commandGenerate} if result == nil { return summary } summary.ReportID = result.ReportID summary.ReportName = result.ReportName summary.PromptID = result.PromptID summary.PromptVersion = result.PromptVersion summary.RunID = result.RunID summary.Status = summaryStatusSucceeded summary.GeneratedAt = result.GeneratedAt summary.ValidPeriod = result.ValidPeriod summary.Timezone = result.Timezone summary.ProfileID, summary.BackendID, summary.ModelName = result.ProfileID, result.BackendID, result.ModelName summary.SourceWarnings = append([]weatherdata.SourceWarning(nil), result.SourceWarnings...) summary.ValidationStatus = string(result.ValidationStatus) summary.OutputPath = result.OutputPath summary.LLMDebugPath = result.LLMDebugPath summary.Notification = newGenerateNotificationSummary(result.Notification) if err != nil { summary.Status = summaryStatusFailed summary.Error = err.Error() } return summary } func newGenerateNotificationSummary(result *app.NotificationResult) *generateNotificationSummary { if result == nil { return nil } summary := &generateNotificationSummary{ Status: result.Status, UploadStatus: result.UploadStatus, StatusError: result.StatusError, RunID: result.RunID, PipelineID: result.PipelineID, BundleID: result.BundleID, IdempotencyKey: result.IdempotencyKey, StartedAt: result.StartedAt, FinishedAt: result.FinishedAt, Error: result.Error, } if !result.AcceptedAt.IsZero() { acceptedAt := result.AcceptedAt summary.AcceptedAt = &acceptedAt } return summary } func newBatchSummary(result *app.BatchResult) batchSummary { summary := batchSummary{Command: commandRun} if result == nil { return summary } summary.Batch = result.Batch summary.Status = batchSummaryStatus(result) summary.StartedAt = result.StartedAt summary.FinishedAt = result.FinishedAt summary.Total = result.Total summary.Succeeded = result.Succeeded summary.Failed = result.Failed summary.Notification = result.Notification summary.Reports = append([]app.BatchReportResult(nil), result.Reports...) if summary.Status == summaryStatusFailed { summary.Error = app.BatchError{Result: result}.Error() } return summary } func newComparisonSummary(result *app.ComparisonResult, err error) comparisonSummary { summary := comparisonSummary{Command: commandCompare, Results: []comparisonProfileSummary{}} if result == nil { return summary } summary.ComparisonID = result.ComparisonID summary.ReportID, summary.ReportName = result.ReportID, result.ReportName summary.PromptID, summary.PromptVersion, summary.PromptHash = result.PromptID, result.PromptVersion, result.PromptHash summary.StartedAt, summary.FinishedAt = result.StartedAt, result.FinishedAt summary.Timezone, summary.ValidPeriod = result.Timezone, result.ValidPeriod summary.OutputDirectory = result.OutputDirectory summary.ManifestPath, summary.DataPackagePath = result.ManifestPath, result.DataPackagePath summary.Total, summary.Succeeded, summary.Failed = result.Total, result.Succeeded, result.Failed for _, profile := range result.Results { summary.Results = append(summary.Results, comparisonProfileSummary{ Position: profile.Position, ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName, Status: profile.Status, ValidationStatus: string(profile.ValidationStatus), ReportPath: profile.ReportPath, LLMDebugPath: profile.LLMDebugPath, Error: profile.Error, }) } summary.Status = comparisonSummaryStatus(result, err) if err != nil { summary.Error = safeComparisonSummaryError(err) } return summary } func comparisonSummaryStatus(result *app.ComparisonResult, err error) string { if result == nil || err != nil || result.Total < 2 || result.Succeeded != result.Total || result.Failed != 0 || result.ManifestPath == "" || result.DataPackagePath == "" { return summaryStatusFailed } return summaryStatusSucceeded } func safeComparisonSummaryError(err error) *comparison.SafeError { if aggregate, ok := comparisonAggregateErrorMessage(err); ok { safe := comparison.NewSafeError("application", aggregate) return &safe } var cleanupErr *comparison.PublicationCleanupError if errors.As(err, &cleanupErr) { safe := comparison.NewSafeError("publication_cleanup", "comparison published but cleanup did not complete") return &safe } var destinationErr *comparison.DestinationError if errors.As(err, &destinationErr) { safe := comparison.NewSafeError("destination_"+string(destinationErr.Kind), "comparison destination preflight failed") return &safe } if category := promptexec.CategoryOf(err); category != "" { safe := comparison.NewSafeError(string(category), "comparison prompt operation failed") return &safe } if errors.Is(err, context.DeadlineExceeded) { safe := comparison.NewSafeError("deadline_exceeded", "comparison deadline exceeded") return &safe } if errors.Is(err, context.Canceled) { safe := comparison.NewSafeError("canceled", "comparison canceled") return &safe } safe := comparison.NewSafeError("application", "comparison did not complete") return &safe } func comparisonAggregateErrorMessage(err error) (string, bool) { const prefix = "comparison completed with " const suffix = " failed profiles" for candidate := err; candidate != nil; candidate = errors.Unwrap(candidate) { value := candidate.Error() if !strings.HasPrefix(value, prefix) || !strings.HasSuffix(value, suffix) { continue } count, parseErr := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(value, prefix), suffix)) if parseErr == nil && count > 0 { return fmt.Sprintf("comparison completed with %d failed profiles", count), true } } return "", false } func batchSummaryStatus(result *app.BatchResult) string { if result == nil { return "" } if result.Failed > 0 || (result.Notification != nil && result.Notification.Status == summaryStatusFailed) { return summaryStatusFailed } return summaryStatusSucceeded } func reportName(id report.ID) string { definition, err := report.DefaultRegistry().Lookup(id) if err != nil { return string(id) } return definition.Name }