diff --git a/docs/internal/state.md b/docs/internal/state.md index ebd94af..6cf51a8 100644 --- a/docs/internal/state.md +++ b/docs/internal/state.md @@ -16,6 +16,7 @@ Inputs: - module snapshot - prompt input data package - preflight artifact +- generated-text raw, run-result, validated text, and render-context artifacts - rendered report path preparation request - RunID for inspection lookups @@ -24,10 +25,15 @@ Outputs: - module snapshot JSON path - prompt input data package YAML path - render preflight JSON path +- generated-text raw JSON path +- generated-text run-result JSON path +- validated generated-text JSON path +- render context JSON path - managed Markdown report path - metadata JSON path - prior comparable snapshot metadata -- loaded module snapshot or data package +- loaded module snapshot, data package, generated text, generated-text run + result, or render context - recent report records for inspection ## Boundaries @@ -61,6 +67,10 @@ valid-period start date for dated artifacts, and the RunID. / snapshots///.modules.json snapshots///.metadata.json + snapshots///.generated_text.raw.json + snapshots///.generated_text.run.json + snapshots///.generated_text.json + snapshots///.render_context.json data-packages///.data_package.yaml preflight///.render.json notifications///.distributor.json @@ -69,8 +79,11 @@ valid-period start date for dated artifacts, and the RunID. Metadata is stored beside module snapshots and links the module snapshot, data package, preflight, report paths, notification path when attempted, and -configured prompt location. Report listing walks metadata files under the -snapshots directory. +configured prompt location. For generated-text-template reports, metadata also +records the generated text schema ID and links the raw generated text, +Scriptorium run result, validated generated text, and render context artifacts. +Markdown-report metadata omits those generated-text fields. Report listing +walks metadata files under the snapshots directory. ## Prior Lookup @@ -89,17 +102,20 @@ current report definition. ## Writes And Inspection -Durable JSON writes use shared atomic file helpers. Managed Markdown reports are -prepared by creating their parent directory; Scriptorium writes the report body -to the prepared path. Extra Markdown copies are handled by app orchestration. -Distributor notification debug artifacts are written atomically when -notification is attempted and include rendered distributor pipeline ID, bundle -ID, idempotency key, bundle paths, upload status, latest run status, and -redacted errors. +Durable JSON writes use shared atomic file helpers. Generated-text raw and +validated JSON artifacts are written atomically as bytes; generated-text run +result and render context artifacts are written atomically as JSON. Managed +Markdown reports are prepared by creating their parent directory; Scriptorium +writes the report body to the prepared path. Extra Markdown copies are handled +by app orchestration. Distributor notification debug artifacts are written +atomically when notification is attempted and include rendered distributor +pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest +run status, and redacted errors. -Inspection helpers read existing metadata, module snapshot, and data package -files. Missing metadata directories return no inspection records or no prior -snapshot rather than creating state. +Inspection helpers read existing metadata, module snapshot, data package, +generated text, generated-text run result, and render context files. Missing +metadata directories return no inspection records or no prior snapshot rather +than creating state. ## Failure Behavior @@ -121,4 +137,6 @@ Inspect: - Managed paths stay under the configured workspace root. - Artifact grouping comes from report definitions. - Metadata links artifacts produced for a run. +- Generated-text artifacts live under the snapshots tree beside module + snapshots and metadata. - Prior lookup is based on structured metadata, not rendered report text. diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go index 70f5e63..63b2ed7 100644 --- a/internal/state/filesystem.go +++ b/internal/state/filesystem.go @@ -27,12 +27,16 @@ type FilesystemStore struct { } type ArtifactPaths struct { - ModuleSnapshot string `json:"moduleSnapshot"` - Metadata string `json:"metadata"` - DataPackage string `json:"dataPackage"` - Preflight string `json:"preflight"` - Notification string `json:"notification,omitempty"` - RenderedReport string `json:"renderedReport,omitempty"` + ModuleSnapshot string `json:"moduleSnapshot"` + Metadata string `json:"metadata"` + DataPackage string `json:"dataPackage"` + Preflight string `json:"preflight"` + Notification string `json:"notification,omitempty"` + RenderedReport string `json:"renderedReport,omitempty"` + GeneratedTextRaw string `json:"generatedTextRaw,omitempty"` + GeneratedTextResult string `json:"generatedTextResult,omitempty"` + GeneratedText string `json:"generatedText,omitempty"` + RenderContext string `json:"renderContext,omitempty"` } type ReportRecord struct { @@ -89,12 +93,16 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error) validDate := resolved.ValidPeriod.Start.Format("2006-01-02") filenameBase := metadata.RunID return ArtifactPaths{ - ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"), - Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"), - DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.yaml"), - Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"), - Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"), - RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"), + ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, filenameBase+".modules.json"), + Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"), + DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.yaml"), + Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"), + Notification: s.join(s.notificationsDir, group, validDate, filenameBase+".distributor.json"), + RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"), + GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.raw.json"), + GeneratedTextResult: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.run.json"), + GeneratedText: s.join(s.snapshotsDir, group, validDate, filenameBase+".generated_text.json"), + RenderContext: s.join(s.snapshotsDir, group, validDate, filenameBase+".render_context.json"), }, nil } @@ -148,6 +156,50 @@ func (s *FilesystemStore) SaveDistributorNotification(_ context.Context, resolve return paths.Notification, nil } +func (s *FilesystemStore) SaveGeneratedTextRaw(_ context.Context, resolved report.Resolved, data []byte) (string, error) { + paths, err := s.Paths(resolved) + if err != nil { + return "", err + } + if err := fileutil.WriteFileAtomic(paths.GeneratedTextRaw, data); err != nil { + return "", err + } + return paths.GeneratedTextRaw, nil +} + +func (s *FilesystemStore) SaveGeneratedTextResult(_ context.Context, resolved report.Resolved, value any) (string, error) { + paths, err := s.Paths(resolved) + if err != nil { + return "", err + } + if err := fileutil.WriteJSONAtomic(paths.GeneratedTextResult, value); err != nil { + return "", err + } + return paths.GeneratedTextResult, nil +} + +func (s *FilesystemStore) SaveGeneratedText(_ context.Context, resolved report.Resolved, data []byte) (string, error) { + paths, err := s.Paths(resolved) + if err != nil { + return "", err + } + if err := fileutil.WriteFileAtomic(paths.GeneratedText, data); err != nil { + return "", err + } + return paths.GeneratedText, nil +} + +func (s *FilesystemStore) SaveRenderContext(_ context.Context, resolved report.Resolved, value any) (string, error) { + paths, err := s.Paths(resolved) + if err != nil { + return "", err + } + if err := fileutil.WriteJSONAtomic(paths.RenderContext, value); err != nil { + return "", err + } + return paths.RenderContext, nil +} + func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) { paths, err := s.Paths(resolved) if err != nil { @@ -325,6 +377,37 @@ func (s *FilesystemStore) LoadModuleSnapshot(_ context.Context, path string) (mo return snapshot, nil } +func (s *FilesystemStore) LoadGeneratedText(_ context.Context, path string) ([]byte, error) { + if path == "" { + return nil, fmt.Errorf("generated text path is required") + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %q: %w", path, err) + } + return data, nil +} + +func (s *FilesystemStore) LoadGeneratedTextResult(_ context.Context, path string, target any) error { + if path == "" { + return fmt.Errorf("generated text result path is required") + } + if target == nil { + return fmt.Errorf("generated text result target is required") + } + return readJSON(path, target) +} + +func (s *FilesystemStore) LoadRenderContext(_ context.Context, path string, target any) error { + if path == "" { + return fmt.Errorf("render context path is required") + } + if target == nil { + return fmt.Errorf("render context target is required") + } + return readJSON(path, target) +} + func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) { var metadata Metadata if err := readJSON(path, &metadata); err != nil { diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index 420ab90..848b853 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -40,6 +40,35 @@ func TestPathsUseRunIDAndWorkspace(t *testing.T) { } } +func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) { + store := newTestStore(t) + resolved := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00") + + paths, err := store.Paths(resolved) + if err != nil { + t.Fatalf("Paths() error = %v", err) + } + + runID := "20260529T100000.000000000Z_hourly" + wants := map[string]string{ + "GeneratedTextRaw": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.raw.json"), + "GeneratedTextResult": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.run.json"), + "GeneratedText": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".generated_text.json"), + "RenderContext": filepath.Join("snapshots", "hourly", "2026-05-29", runID+".render_context.json"), + } + got := map[string]string{ + "GeneratedTextRaw": paths.GeneratedTextRaw, + "GeneratedTextResult": paths.GeneratedTextResult, + "GeneratedText": paths.GeneratedText, + "RenderContext": paths.RenderContext, + } + for name, want := range wants { + if !strings.Contains(got[name], want) { + t.Fatalf("%s path = %q, want component %q", name, got[name], want) + } + } +} + func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { store := newTestStore(t) resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") @@ -180,6 +209,117 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") { t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data)) } + for _, unexpected := range []string{"generatedTextSchemaId", "generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath"} { + if strings.Contains(string(data), unexpected) { + t.Fatalf("metadata JSON includes generated-text field %q for Markdown report:\n%s", unexpected, string(data)) + } + } +} + +func TestSaveGeneratedTextArtifactsAndMetadataRoundTrip(t *testing.T) { + store := newTestStore(t) + resolved := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00") + briefingMetadata := stateBriefingMetadata(resolved) + + rawPath, err := store.SaveGeneratedTextRaw(context.Background(), resolved, []byte(`{"summary":"raw"}`)) + if err != nil { + t.Fatalf("SaveGeneratedTextRaw() error = %v", err) + } + resultPath, err := store.SaveGeneratedTextResult(context.Background(), resolved, map[string]any{ + "command": []string{"scriptorium", "run"}, + "status": "succeeded", + }) + if err != nil { + t.Fatalf("SaveGeneratedTextResult() error = %v", err) + } + generatedPath, err := store.SaveGeneratedText(context.Background(), resolved, []byte(`{"summary":"Storm chances increase.","timing":"Late morning.","impacts":"Brief downpours."}`)) + if err != nil { + t.Fatalf("SaveGeneratedText() error = %v", err) + } + contextPath, err := store.SaveRenderContext(context.Background(), resolved, struct { + ReportTitle string `json:"reportTitle"` + Location string `json:"location"` + }{ + ReportTitle: "Hourly Report", + Location: "Brentwood", + }) + if err != nil { + t.Fatalf("SaveRenderContext() error = %v", err) + } + + for _, path := range []string{rawPath, resultPath, generatedPath, contextPath} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected generated-text artifact %q: %v", path, err) + } + } + rawData, err := store.LoadGeneratedText(context.Background(), rawPath) + if err != nil { + t.Fatalf("LoadGeneratedText() raw error = %v", err) + } + if string(rawData) != `{"summary":"raw"}` { + t.Fatalf("raw data = %q, want saved bytes", rawData) + } + generatedData, err := store.LoadGeneratedText(context.Background(), generatedPath) + if err != nil { + t.Fatalf("LoadGeneratedText() generated error = %v", err) + } + if !strings.Contains(string(generatedData), `"timing":"Late morning."`) { + t.Fatalf("generated text = %q, want saved normalized JSON", generatedData) + } + var runResult struct { + Command []string `json:"command"` + Status string `json:"status"` + } + if err := store.LoadGeneratedTextResult(context.Background(), resultPath, &runResult); err != nil { + t.Fatalf("LoadGeneratedTextResult() error = %v", err) + } + if runResult.Status != "succeeded" || strings.Join(runResult.Command, " ") != "scriptorium run" { + t.Fatalf("run result = %#v, want saved result", runResult) + } + var renderContext struct { + ReportTitle string `json:"reportTitle"` + Location string `json:"location"` + } + if err := store.LoadRenderContext(context.Background(), contextPath, &renderContext); err != nil { + t.Fatalf("LoadRenderContext() error = %v", err) + } + if renderContext.ReportTitle != "Hourly Report" || renderContext.Location != "Brentwood" { + t.Fatalf("render context = %#v, want saved context", renderContext) + } + + paths, err := store.Paths(resolved) + if err != nil { + t.Fatalf("Paths() error = %v", err) + } + metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{ + ModuleSnapshot: paths.ModuleSnapshot, + Metadata: paths.Metadata, + DataPackage: paths.DataPackage, + Preflight: paths.Preflight, + RenderedReport: paths.RenderedReport, + GeneratedTextRaw: rawPath, + GeneratedTextResult: resultPath, + GeneratedText: generatedPath, + RenderContext: contextPath, + }) + metadataPath, err := store.SaveMetadata(context.Background(), metadata) + if err != nil { + t.Fatalf("SaveMetadata() error = %v", err) + } + var decoded Metadata + metadataData, err := os.ReadFile(metadataPath) + if err != nil { + t.Fatalf("read metadata: %v", err) + } + if err := json.Unmarshal(metadataData, &decoded); err != nil { + t.Fatalf("decode metadata: %v", err) + } + if decoded.GeneratedTextSchemaID != "hourly" { + t.Fatalf("GeneratedTextSchemaID = %q, want hourly", decoded.GeneratedTextSchemaID) + } + if decoded.GeneratedTextRawPath != rawPath || decoded.GeneratedTextResultPath != resultPath || decoded.GeneratedTextPath != generatedPath || decoded.RenderContextPath != contextPath { + t.Fatalf("metadata generated-text paths = %#v, want saved artifact paths", decoded) + } } func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) { @@ -471,5 +611,9 @@ func pathsString(paths ArtifactPaths) string { paths.Preflight, paths.Notification, paths.RenderedReport, + paths.GeneratedTextRaw, + paths.GeneratedTextResult, + paths.GeneratedText, + paths.RenderContext, }, "\n") } diff --git a/internal/state/metadata.go b/internal/state/metadata.go index c935347..ac7b304 100644 --- a/internal/state/metadata.go +++ b/internal/state/metadata.go @@ -12,30 +12,35 @@ import ( const MetadataSchemaVersion = "weatherreporter.metadata.v1" 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"` - PreflightPath string `json:"preflightPath"` - NotificationPath string `json:"notificationPath,omitempty"` - RenderedReportPath string `json:"renderedReportPath,omitempty"` + 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"` + PreflightPath string `json:"preflightPath"` + 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 BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata { metadata := resolved.Metadata() - return Metadata{ + out := Metadata{ SchemaVersion: MetadataSchemaVersion, RunID: metadata.RunID, MetadataPath: paths.Metadata, @@ -55,6 +60,14 @@ func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadat PreflightPath: paths.Preflight, RenderedReportPath: paths.RenderedReport, } + if resolved.Definition.GenerationMode == report.GenerationModeGeneratedTextTemplate { + out.GeneratedTextSchemaID = resolved.Definition.GeneratedTextSchemaID + out.GeneratedTextRawPath = paths.GeneratedTextRaw + out.GeneratedTextResultPath = paths.GeneratedTextResult + out.GeneratedTextPath = paths.GeneratedText + out.RenderContextPath = paths.RenderContext + } + return out } func copyLocation(location *briefing.LocationContext) *briefing.LocationContext { diff --git a/internal/state/store.go b/internal/state/store.go index 40cd3c9..8ebe0fe 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -17,10 +17,17 @@ type Store interface { SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error) SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error) SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (string, error) + SaveGeneratedTextRaw(context.Context, report.Resolved, []byte) (string, error) + SaveGeneratedTextResult(context.Context, report.Resolved, any) (string, error) + SaveGeneratedText(context.Context, report.Resolved, []byte) (string, error) + SaveRenderContext(context.Context, report.Resolved, any) (string, error) PrepareRenderedReport(context.Context, report.Resolved) (string, error) SaveMetadata(context.Context, Metadata) (string, error) FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error) LoadModuleSnapshot(context.Context, string) (module.Snapshot, error) + LoadGeneratedText(context.Context, string) ([]byte, error) + LoadGeneratedTextResult(context.Context, string, any) error + LoadRenderContext(context.Context, string, any) error } type PriorSnapshot struct {