package state import ( "encoding/json" "fmt" "strings" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) const ( MetadataSchemaVersionV1 = "weatherreporter.metadata.v1" MetadataSchemaVersion = "weatherreporter.metadata.v2" ) // Metadata is the durable record used for report discovery and inspection. // V1 fields remain internal compatibility values and are emitted only for V1 // records; V2 records have no legacy aliases in their JSON representation. type Metadata struct { SchemaVersion string `json:"schemaVersion"` RunID string `json:"runId"` MetadataPath string `json:"-"` ReportID report.ID `json:"reportId"` Variant string `json:"variant,omitempty"` PromptID string `json:"promptId"` GeneratedAt time.Time `json:"generatedAt"` Timezone string `json:"timezone"` ValidPeriod timeutil.Period `json:"validPeriod"` Location *briefing.LocationContext `json:"location,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"` Sources []briefing.SourceMetadata `json:"sources,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` ModuleSnapshotPath string `json:"moduleSnapshotPath"` DataPackagePath string `json:"dataPackagePath"` PreparationPath string `json:"preparationPath,omitempty"` ExecutionPath string `json:"executionPath,omitempty"` NotificationPath string `json:"notificationPath,omitempty"` RenderedReportPath string `json:"renderedReportPath,omitempty"` GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"` GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"` GeneratedTextPath string `json:"generatedTextPath,omitempty"` RenderContextPath string `json:"renderContextPath,omitempty"` // These paths are retained only to read legacy V1 records. They are never // emitted in V2 metadata. PreflightPath string `json:"-"` GeneratedTextResultPath string `json:"-"` } type metadataJSON struct { SchemaVersion string `json:"schemaVersion"` RunID string `json:"runId"` ReportID report.ID `json:"reportId"` Variant string `json:"variant,omitempty"` PromptID string `json:"promptId"` GeneratedAt time.Time `json:"generatedAt"` Timezone string `json:"timezone"` ValidPeriod timeutil.Period `json:"validPeriod"` Location *briefing.LocationContext `json:"location,omitempty"` SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"` Sources []briefing.SourceMetadata `json:"sources,omitempty"` SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"` ModuleSnapshotPath string `json:"moduleSnapshotPath"` DataPackagePath string `json:"dataPackagePath"` PreparationPath string `json:"preparationPath,omitempty"` ExecutionPath string `json:"executionPath,omitempty"` PreflightPath string `json:"preflightPath,omitempty"` NotificationPath string `json:"notificationPath,omitempty"` RenderedReportPath string `json:"renderedReportPath,omitempty"` GeneratedTextSchemaID string `json:"generatedTextSchemaId,omitempty"` GeneratedTextRawPath string `json:"generatedTextRawPath,omitempty"` GeneratedTextResultPath string `json:"generatedTextResultPath,omitempty"` GeneratedTextPath string `json:"generatedTextPath,omitempty"` RenderContextPath string `json:"renderContextPath,omitempty"` } func (m Metadata) MarshalJSON() ([]byte, error) { w := metadataJSON{ SchemaVersion: m.SchemaVersion, RunID: m.RunID, ReportID: m.ReportID, Variant: m.Variant, PromptID: m.PromptID, GeneratedAt: m.GeneratedAt, Timezone: m.Timezone, ValidPeriod: m.ValidPeriod, Location: m.Location, SourceLocationID: m.SourceLocationID, SourceLocation: m.SourceLocation, Sources: m.Sources, SourceWarnings: m.SourceWarnings, ModuleSnapshotPath: m.ModuleSnapshotPath, DataPackagePath: m.DataPackagePath, NotificationPath: m.NotificationPath, RenderedReportPath: m.RenderedReportPath, GeneratedTextSchemaID: m.GeneratedTextSchemaID, GeneratedTextRawPath: m.GeneratedTextRawPath, GeneratedTextPath: m.GeneratedTextPath, RenderContextPath: m.RenderContextPath, } switch m.SchemaVersion { case MetadataSchemaVersionV1: w.PreflightPath = m.PreflightPath w.GeneratedTextResultPath = m.GeneratedTextResultPath case MetadataSchemaVersion: w.PreparationPath = m.PreparationPath w.ExecutionPath = m.ExecutionPath default: return nil, fmt.Errorf("unsupported metadata schema version %q", m.SchemaVersion) } return json.Marshal(w) } func (m *Metadata) UnmarshalJSON(data []byte) error { var header struct { SchemaVersion string `json:"schemaVersion"` } if err := json.Unmarshal(data, &header); err != nil { return err } if header.SchemaVersion != MetadataSchemaVersionV1 && header.SchemaVersion != MetadataSchemaVersion { return fmt.Errorf("unsupported metadata schema version %q", header.SchemaVersion) } var w metadataJSON if err := json.Unmarshal(data, &w); err != nil { return err } *m = Metadata{ SchemaVersion: w.SchemaVersion, RunID: w.RunID, ReportID: w.ReportID, Variant: w.Variant, PromptID: w.PromptID, GeneratedAt: w.GeneratedAt, Timezone: w.Timezone, ValidPeriod: w.ValidPeriod, Location: w.Location, SourceLocationID: w.SourceLocationID, SourceLocation: w.SourceLocation, Sources: w.Sources, SourceWarnings: w.SourceWarnings, ModuleSnapshotPath: w.ModuleSnapshotPath, DataPackagePath: w.DataPackagePath, NotificationPath: w.NotificationPath, RenderedReportPath: w.RenderedReportPath, GeneratedTextSchemaID: w.GeneratedTextSchemaID, GeneratedTextRawPath: w.GeneratedTextRawPath, GeneratedTextPath: w.GeneratedTextPath, RenderContextPath: w.RenderContextPath, } if w.SchemaVersion == MetadataSchemaVersionV1 { m.PreflightPath = w.PreflightPath m.GeneratedTextResultPath = w.GeneratedTextResultPath m.PreparationPath = w.PreflightPath m.ExecutionPath = w.GeneratedTextResultPath } else { m.PreparationPath = w.PreparationPath m.ExecutionPath = w.ExecutionPath } return nil } func (m Metadata) Validate() error { if strings.TrimSpace(m.RunID) == "" { return fmt.Errorf("metadata run id is required") } if strings.TrimSpace(m.ModuleSnapshotPath) == "" { return fmt.Errorf("metadata module snapshot path is required") } if strings.TrimSpace(m.DataPackagePath) == "" { return fmt.Errorf("metadata data package path is required") } if strings.TrimSpace(m.MetadataPath) == "" { return fmt.Errorf("metadata path is required") } switch m.SchemaVersion { case MetadataSchemaVersionV1: if strings.TrimSpace(m.PreflightPath) == "" { return fmt.Errorf("metadata preflight path is required") } case MetadataSchemaVersion: if strings.TrimSpace(m.PreparationPath) == "" { return fmt.Errorf("metadata preparation path is required") } default: return fmt.Errorf("unsupported metadata schema version %q", m.SchemaVersion) } return nil } // BuildPromptMetadataFromBriefingMetadata creates the V2 record used by the // prompt execution workflow. Callers populate preparation and execution paths // only after their corresponding artifacts have been saved. func BuildPromptMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata { metadata := resolved.Metadata() return Metadata{ SchemaVersion: MetadataSchemaVersion, RunID: metadata.RunID, MetadataPath: paths.Metadata, ReportID: metadata.ReportID, Variant: briefingMetadata.Variant, PromptID: metadata.PromptID, GeneratedAt: metadata.GeneratedAt, Timezone: metadata.Timezone, ValidPeriod: metadata.ValidPeriod, Location: copyLocation(briefingMetadata.Location), SourceLocationID: briefingMetadata.SourceLocationID, SourceLocation: briefingMetadata.SourceLocation, Sources: briefingMetadata.Sources, SourceWarnings: briefingMetadata.SourceWarnings, ModuleSnapshotPath: paths.ModuleSnapshot, DataPackagePath: paths.DataPackage, RenderedReportPath: paths.RenderedReport, GeneratedTextSchemaID: resolved.Definition.GeneratedTextSchemaID, GeneratedTextRawPath: paths.GeneratedTextRaw, GeneratedTextPath: paths.GeneratedText, RenderContextPath: paths.RenderContext, } } func copyLocation(location *briefing.LocationContext) *briefing.LocationContext { if location == nil { return nil } copied := *location return &copied }