package artifacts import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) const ( ArtifactTranscriptMerged = "narratio.transcript.merged" ArtifactTranscriptPolished = "narratio.transcript.polished" ArtifactTranscriptFull = "narratio.transcript.full" ArtifactTranscriptTrimmed = "narratio.transcript.trimmed" ArtifactBoundsSession = "narratio.bounds.session" ArtifactSessionRecap = "narratio.artifact.session_recap" ) // ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID. var ErrSessionArtifactNotFound = errors.New("session artifact not found") type artifactContentKind string const ( contentTranscriptJSON artifactContentKind = "transcript_json" contentJSON artifactContentKind = "json" contentText artifactContentKind = "text" ) type artifactSpec struct { ID string CanonicalRelPath string ProducerStage string OutputKind string ContentKind artifactContentKind } var artifactRegistry = map[string]artifactSpec{ ArtifactTranscriptMerged: { ID: ArtifactTranscriptMerged, CanonicalRelPath: "transcripts/merged.json", ProducerStage: "merge", OutputKind: "transcript_merged", ContentKind: contentTranscriptJSON, }, ArtifactTranscriptPolished: { ID: ArtifactTranscriptPolished, CanonicalRelPath: "transcripts/processed.json", ProducerStage: "polish", OutputKind: "transcript_processed", ContentKind: contentTranscriptJSON, }, ArtifactTranscriptFull: { ID: ArtifactTranscriptFull, CanonicalRelPath: "transcripts/normalized.json", ProducerStage: "normalize", OutputKind: "transcript_normalized", ContentKind: contentTranscriptJSON, }, ArtifactTranscriptTrimmed: { ID: ArtifactTranscriptTrimmed, CanonicalRelPath: "transcripts/trimmed.json", ProducerStage: "trim", OutputKind: "transcript_trimmed", ContentKind: contentTranscriptJSON, }, ArtifactBoundsSession: { ID: ArtifactBoundsSession, CanonicalRelPath: "artifacts/session_bounds.json", ProducerStage: "trim", OutputKind: "session_bounds", ContentKind: contentJSON, }, ArtifactSessionRecap: { ID: ArtifactSessionRecap, CanonicalRelPath: "artifacts/session_recap.md", ProducerStage: "analyze", OutputKind: "session_recap", ContentKind: contentText, }, } var artifactAliases = map[string]string{ "processed_transcript": ArtifactTranscriptPolished, "normalized_transcript": ArtifactTranscriptFull, "trimmed_transcript": ArtifactTranscriptTrimmed, } // ResolvedSessionArtifact describes one session-level artifact lookup result. type ResolvedSessionArtifact struct { ID string Path string ProducerStage string OutputKind string ProducerRunID string Provenance string } // SessionArtifactNotFoundError includes context when a known artifact cannot be read. type SessionArtifactNotFoundError struct { ArtifactID string } func (e *SessionArtifactNotFoundError) Error() string { return fmt.Sprintf("%s: %q", ErrSessionArtifactNotFound, e.ArtifactID) } func (e *SessionArtifactNotFoundError) Unwrap() error { return ErrSessionArtifactNotFound } // NormalizeSessionArtifactSource maps legacy aliases to canonical IDs and validates IDs. func NormalizeSessionArtifactSource(source string) (string, error) { normalized := strings.TrimSpace(source) if normalized == "" { return "", fmt.Errorf("artifact source is required") } if alias, ok := artifactAliases[normalized]; ok { normalized = alias } if _, ok := artifactRegistry[normalized]; !ok { return "", fmt.Errorf("unsupported artifact source %q", source) } return normalized, nil } // ResolveSessionArtifact resolves a symbolic source to a readable local session artifact path. // Resolution order is manifest producer outputs first, then canonical session path fallback. func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source string) (ResolvedSessionArtifact, error) { id, err := NormalizeSessionArtifactSource(source) if err != nil { return ResolvedSessionArtifact{}, err } spec := artifactRegistry[id] for _, candidate := range manifestArtifactCandidates(paths, m, spec) { exists, isDir, statErr := pathExists(candidate.Path) if statErr != nil { return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", candidate.Path, statErr) } if !exists || isDir { continue } resolved := candidate resolved.ID = spec.ID resolved.ProducerStage = spec.ProducerStage resolved.OutputKind = spec.OutputKind if err := validateResolvedContent(resolved.Path, spec.ContentKind); err != nil { return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", resolved.ID, err) } return resolved, nil } fallbackPath := filepath.Join(paths.Root, filepath.FromSlash(spec.CanonicalRelPath)) exists, isDir, statErr := pathExists(fallbackPath) if statErr != nil { return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", fallbackPath, statErr) } if exists && !isDir { if err := validateResolvedContent(fallbackPath, spec.ContentKind); err != nil { return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", spec.ID, err) } return ResolvedSessionArtifact{ ID: spec.ID, Path: filepath.Clean(fallbackPath), ProducerStage: spec.ProducerStage, OutputKind: spec.OutputKind, Provenance: "fallback.canonical_path", }, nil } return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID} } func manifestArtifactCandidates(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) []ResolvedSessionArtifact { if m == nil || len(m.Stages) == 0 || spec.ProducerStage == "" || spec.OutputKind == "" { return nil } sr := m.Stages[spec.ProducerStage] if sr == nil { return nil } candidates := make([]ResolvedSessionArtifact, 0, len(sr.Outputs)) for _, out := range sr.Outputs { if strings.TrimSpace(out.Kind) != spec.OutputKind { continue } if strings.TrimSpace(out.LocalPath) == "" { continue } resolved := filepath.Clean(ResolveSessionLocalPathForRead(paths, out.LocalPath)) if resolved == "" { continue } candidates = append(candidates, ResolvedSessionArtifact{ Path: resolved, ProducerRunID: strings.TrimSpace(out.ProducerRunID), Provenance: "manifest." + spec.ProducerStage + ".outputs", }) } return dedupeResolvedArtifacts(candidates) } func dedupeResolvedArtifacts(values []ResolvedSessionArtifact) []ResolvedSessionArtifact { seen := map[string]struct{}{} out := make([]ResolvedSessionArtifact, 0, len(values)) for _, value := range values { key := filepath.Clean(strings.TrimSpace(value.Path)) if key == "" { continue } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} value.Path = key out = append(out, value) } return out } func pathExists(path string) (exists bool, isDir bool, err error) { info, err := os.Stat(path) if err == nil { return true, info.IsDir(), nil } if errors.Is(err, os.ErrNotExist) { return false, false, nil } return false, false, err } func validateResolvedContent(path string, kind artifactContentKind) error { switch kind { case contentTranscriptJSON: return validateTranscriptSegmentsJSON(path) case contentJSON: return validateJSONContent(path) case contentText: return validateNonEmptyContent(path) default: return fmt.Errorf("unsupported content kind %q", kind) } } func validateTranscriptSegmentsJSON(path string) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read file: %w", err) } var payload map[string]any if err := json.Unmarshal(data, &payload); err != nil { return fmt.Errorf("decode json: %w", err) } segments, ok := payload["segments"] if !ok { return fmt.Errorf("top-level segments is required") } if _, ok := segments.([]any); !ok { return fmt.Errorf("top-level segments must be an array") } return nil } func validateJSONContent(path string) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read file: %w", err) } var payload any if err := json.Unmarshal(data, &payload); err != nil { return fmt.Errorf("decode json: %w", err) } return nil } func validateNonEmptyContent(path string) error { info, err := os.Stat(path) if err != nil { return fmt.Errorf("stat file: %w", err) } if info.IsDir() { return fmt.Errorf("path is a directory") } if info.Size() <= 0 { return fmt.Errorf("file is empty") } return nil }