package transcript import ( "context" "crypto/sha256" "encoding/hex" "fmt" "math" "math/big" "strconv" "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" ) const Key = "seriatim" const ( DocumentKind = "transcript" UnitKind = "transcript_segment" Format = "application/vnd.seriatim+json" ) var providedCapabilities = []string{ "source.transcript", "transcript.speaker", "transcript.timestamps", } var _ contracts.InputAdapter = (*Adapter)(nil) type Options struct{} type Adapter struct { options Options } func New() *Adapter { options, _ := DecodeOptions(nil) return &Adapter{options: options} } func (a *Adapter) Key() string { return Key } func (a *Adapter) Parse(ctx context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) { if ctx == nil { return nil, inputErrorf("context must not be nil") } if err := ctx.Err(); err != nil { return nil, inputErrorf("context error before parsing: %w", err) } if len(req.Raw) == 0 { return nil, inputErrorf("raw input must not be empty") } parsed, err := decodeTranscript(req.Raw) if err != nil { return nil, inputErrorf("parse JSON: %w", err) } if len(parsed.Segments) == 0 { return nil, inputErrorf("segments must not be empty") } rawDigest := digest(req.Raw) doc := &source.SourceDocument{ ID: documentID(req.SourceID, parsed.Metadata, rawDigest), Kind: DocumentKind, Format: Format, Metadata: copyMetadata(parsed.Metadata), } seenSegmentIDs := make(map[int]struct{}, len(parsed.Segments)) for i, segment := range parsed.Segments { unit, err := sourceUnit(doc.ID, segment, i, seenSegmentIDs) if err != nil { return nil, err } doc.Units = append(doc.Units, unit) } doc.Digest, err = source.DigestDocument(doc) if err != nil { return nil, inputErrorf("digest source document: %w", err) } if err := source.ValidateDocument(doc); err != nil { return nil, inputErrorf("validate source document: %w", err) } return doc, nil } func ModuleSpec() pipeline.ModuleSpec { return pipeline.ModuleSpec{ Key: Key, Stage: pipeline.StageInput, ExecutionClass: contracts.ExecutionClassDeterministic, Provides: append([]string(nil), providedCapabilities...), } } func Register(registry *pipeline.InputAdapterRegistry) error { return registry.RegisterBuilderWithSpec(ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.InputAdapter, error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err } return &Adapter{options: options}, nil }) } func validateOptions(options map[string]any) error { _, err := DecodeOptions(options) return err } func DecodeOptions(options map[string]any) (Options, error) { if err := pipeline.RejectUnknownOptions(options); err != nil { return Options{}, inputErrorf("%w", err) } return Options{}, nil } func sourceUnit(sourceID string, segment segment, index int, seen map[int]struct{}) (source.SourceUnit, error) { segmentLabel := fmt.Sprintf("segment[%d]", index) if segment.ID <= 0 { return source.SourceUnit{}, inputErrorf("%s id must be positive", segmentLabel) } if _, ok := seen[segment.ID]; ok { return source.SourceUnit{}, inputErrorf("segment id %d is duplicated", segment.ID) } seen[segment.ID] = struct{}{} speaker := strings.TrimSpace(segment.Speaker) if speaker == "" { return source.SourceUnit{}, inputErrorf("segment %d speaker must not be empty", segment.ID) } start, err := validTimestamp(segment.Start, fmt.Sprintf("segment %d start", segment.ID)) if err != nil { return source.SourceUnit{}, err } end, err := validTimestamp(segment.End, fmt.Sprintf("segment %d end", segment.ID)) if err != nil { return source.SourceUnit{}, err } if end.Cmp(start) < 0 { return source.SourceUnit{}, inputErrorf("segment %d end must be greater than or equal to start", segment.ID) } if strings.TrimSpace(segment.Text) == "" { return source.SourceUnit{}, inputErrorf("segment %d text must not be empty", segment.ID) } return source.SourceUnit{ ID: segment.ID, Kind: UnitKind, Text: segment.Text, Ref: source.SourceRef{ SourceID: sourceID, StartUnitID: segment.ID, EndUnitID: segment.ID, }, Metadata: map[string]any{ MetadataSpeaker: segment.Speaker, MetadataStart: segment.Start, MetadataEnd: segment.End, }, }, nil } func validTimestamp(value fmt.Stringer, label string) (*big.Rat, error) { raw := strings.TrimSpace(value.String()) if raw == "" { return nil, inputErrorf("%s must not be empty", label) } parsed, err := strconv.ParseFloat(raw, 64) if err != nil { return nil, inputErrorf("%s must be a valid number: %w", label, err) } if math.IsInf(parsed, 0) || math.IsNaN(parsed) { return nil, inputErrorf("%s must be finite", label) } if parsed < 0 { return nil, inputErrorf("%s must not be negative", label) } rat, ok := new(big.Rat).SetString(raw) if !ok { return nil, inputErrorf("%s must be a valid number", label) } return rat, nil } func documentID(requestedID string, metadata map[string]any, rawDigest string) string { if id := strings.TrimSpace(requestedID); id != "" { return id } if id := stringMetadata(metadata, "id"); id != "" { return id } if id := stringMetadata(metadata, "source_id"); id != "" { return id } return "seriatim:" + strings.TrimPrefix(rawDigest, "sha256:")[:16] } func stringMetadata(metadata map[string]any, key string) string { value, ok := metadata[key].(string) if !ok { return "" } return strings.TrimSpace(value) } func copyMetadata(metadata map[string]any) map[string]any { if len(metadata) == 0 { return nil } copied := make(map[string]any, len(metadata)) for key, value := range metadata { copied[key] = value } return copied } func digest(raw []byte) string { sum := sha256.Sum256(raw) return "sha256:" + hex.EncodeToString(sum[:]) } func inputErrorf(format string, args ...any) error { return fmt.Errorf("seriatim input: "+format, args...) }