// Package scenedescriptions normalizes merged D&D scene descriptions. package scenedescriptions import ( "context" "fmt" "sort" "strings" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/shape" ) const ( Key = "dnd/scene-descriptions" normalizerPolicy = "dnd.scene_descriptions.normalizer.v1" ) var requiredCapabilities = []string{"merged"} var providedCapabilities = []string{"normalized"} var _ contracts.Normalizer[dnd.SceneDescriptionList] = (*Normalizer)(nil) var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) type Options struct{} type Normalizer struct{} func New(Options) *Normalizer { return &Normalizer{} } func (n *Normalizer) Key() string { return Key } func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (n *Normalizer) ManifestMetadata() map[string]any { if n == nil { return nil } return map[string]any{"normalizer_policy": normalizerPolicy} } func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { if n == nil { return nil } return []pipeline.CheckpointFingerprint{{Name: "normalizer_policy", Value: normalizerPolicy}} } func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.SceneDescriptionList]) (contracts.TypedNormalizeResult[dnd.SceneDescriptionList], error) { if n == nil { return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("normalizer must not be nil") } if ctx == nil { return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("context must not be nil") } if err := ctx.Err(); err != nil { return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("context error before normalize: %w", err) } value, err := normalizeList(req.MergeOutput.Value, req.Source) if err != nil { return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("normalize scenes: %w", err) } return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{Value: value}, nil } func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (dnd.SceneDescriptionList, error) { if doc == nil { return dnd.SceneDescriptionList{}, fmt.Errorf("source document must not be nil") } if input.Scenes == nil { return dnd.SceneDescriptionList{}, fmt.Errorf("scenes must be present") } if len(input.Scenes) == 0 { return dnd.SceneDescriptionList{}, fmt.Errorf("scenes must not be empty") } unitPositions := make(map[int]int, len(doc.Units)) for index, unit := range doc.Units { unitPositions[unit.ID] = index } output := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, len(input.Scenes))} for index, scene := range input.Scenes { scene.Title = strings.TrimSpace(scene.Title) scene.Summary = strings.TrimSpace(scene.Summary) if err := shape.Validate(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{scene}}); err != nil { return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d]: %w", index, err) } if err := source.ValidateRef(doc, scene.SourceRef); err != nil { return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d].source_ref: %s", index, diagnostics.Truncate(err.Error())) } output.Scenes[index] = scene } sort.SliceStable(output.Scenes, func(left, right int) bool { leftStart := unitPositions[output.Scenes[left].SourceRef.StartUnitID] rightStart := unitPositions[output.Scenes[right].SourceRef.StartUnitID] if leftStart != rightStart { return leftStart < rightStart } return output.Scenes[left].ID < output.Scenes[right].ID }) unique := make([]dnd.SceneDescription, 0, len(output.Scenes)) byID := make(map[string]dnd.SceneDescription, len(output.Scenes)) byRange := make(map[source.SourceRef]dnd.SceneDescription, len(output.Scenes)) seen := make(map[dnd.SceneDescription]struct{}, len(output.Scenes)) for _, scene := range output.Scenes { if previous, ok := byID[scene.ID]; ok && !identical(previous, scene) { return dnd.SceneDescriptionList{}, fmt.Errorf("scene ID %s has conflicting records", diagnostics.Quote(scene.ID)) } if previous, ok := byRange[scene.SourceRef]; ok && !sameModelContent(previous, scene) { return dnd.SceneDescriptionList{}, fmt.Errorf("source range %s has conflicting records", sourceRefLabel(scene.SourceRef)) } if _, ok := seen[scene]; ok { continue } byID[scene.ID] = scene byRange[scene.SourceRef] = scene seen[scene] = struct{}{} unique = append(unique, scene) } output.Scenes = unique return output, nil } func identical(left, right dnd.SceneDescription) bool { return left == right } func sameModelContent(left, right dnd.SceneDescription) bool { return left.Kind == right.Kind && left.Title == right.Title && left.Summary == right.Summary } func sourceRefLabel(ref source.SourceRef) string { return fmt.Sprintf("%s:%d-%d", diagnostics.Quote(ref.SourceID), ref.StartUnitID, ref.EndUnitID) } func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ Key: Key, Stage: pipeline.StageNormalize, Requires: append([]string(nil), requiredCapabilities...), Provides: append([]string(nil), providedCapabilities...), ArtifactKind: dnd.SceneDescriptionListKind, } } func Register(registry *pipeline.NormalizerRegistry) error { return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.SceneDescriptionList], error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err } return New(options), nil }) } func DecodeOptions(options map[string]any) (Options, error) { if err := pipeline.RejectUnknownOptions(options); err != nil { return Options{}, normalizerErrorf("%w", err) } return Options{}, nil } func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err } func normalizerErrorf(format string, args ...any) error { return fmt.Errorf("dnd scene descriptions normalizer: "+format, args...) }