Implement integer source units and chunk payloads
This commit is contained in:
@@ -69,7 +69,7 @@ func (a *Adapter) Parse(ctx context.Context, req contracts.ParseRequest) (*sourc
|
||||
Metadata: copyMetadata(parsed.Metadata),
|
||||
}
|
||||
|
||||
seenSegmentIDs := make(map[string]struct{}, len(parsed.Segments))
|
||||
seenSegmentIDs := make(map[int]struct{}, len(parsed.Segments))
|
||||
for i, segment := range parsed.Segments {
|
||||
unit, err := sourceUnit(segment, i, seenSegmentIDs)
|
||||
if err != nil {
|
||||
@@ -98,39 +98,35 @@ func Register(registry *pipeline.InputAdapterRegistry) error {
|
||||
})
|
||||
}
|
||||
|
||||
func sourceUnit(segment segment, index int, seen map[string]struct{}) (source.SourceUnit, error) {
|
||||
func sourceUnit(segment segment, index int, seen map[int]struct{}) (source.SourceUnit, error) {
|
||||
segmentLabel := fmt.Sprintf("segment[%d]", index)
|
||||
segmentID := strings.TrimSpace(segment.ID)
|
||||
if segmentID == "" {
|
||||
return source.SourceUnit{}, inputErrorf("%s id must not be empty", segmentLabel)
|
||||
}
|
||||
if segmentID != segment.ID {
|
||||
return source.SourceUnit{}, inputErrorf("%s id %q must not contain leading or trailing whitespace", segmentLabel, segment.ID)
|
||||
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 %q is duplicated", segment.ID)
|
||||
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 %q speaker must not be empty", segment.ID)
|
||||
return source.SourceUnit{}, inputErrorf("segment %d speaker must not be empty", segment.ID)
|
||||
}
|
||||
|
||||
start, err := validTimestamp(segment.Start, fmt.Sprintf("segment %q start", 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 %q end", segment.ID))
|
||||
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 %q end must be greater than or equal to start", segment.ID)
|
||||
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 %q text must not be empty", segment.ID)
|
||||
return source.SourceUnit{}, inputErrorf("segment %d text must not be empty", segment.ID)
|
||||
}
|
||||
|
||||
return source.SourceUnit{
|
||||
|
||||
@@ -41,8 +41,8 @@ func TestParseValidMinimalTranscript(t *testing.T) {
|
||||
}
|
||||
|
||||
first := doc.Units[0]
|
||||
if first.ID != "seg-001" {
|
||||
t.Fatalf("first.ID = %q, want seg-001", first.ID)
|
||||
if first.ID != 1 {
|
||||
t.Fatalf("first.ID = %d, want 1", first.ID)
|
||||
}
|
||||
if first.Kind != UnitKind {
|
||||
t.Fatalf("first.Kind = %q, want %q", first.Kind, UnitKind)
|
||||
@@ -86,13 +86,13 @@ func TestParseAcceptsNumericSegmentIDs(t *testing.T) {
|
||||
if len(doc.Units) != 2 {
|
||||
t.Fatalf("len(doc.Units) = %d, want 2", len(doc.Units))
|
||||
}
|
||||
if doc.Units[0].ID != "1" || doc.Units[1].ID != "2" {
|
||||
t.Fatalf("unit IDs = %#v, want numeric IDs normalized to strings", []string{doc.Units[0].ID, doc.Units[1].ID})
|
||||
if doc.Units[0].ID != 1 || doc.Units[1].ID != 2 {
|
||||
t.Fatalf("unit IDs = %#v, want numeric IDs", []int{doc.Units[0].ID, doc.Units[1].ID})
|
||||
}
|
||||
ref := source.SourceRef{
|
||||
SourceID: doc.ID,
|
||||
StartUnitID: "1",
|
||||
EndUnitID: "2",
|
||||
StartUnitID: 1,
|
||||
EndUnitID: 2,
|
||||
}
|
||||
if err := source.ValidateRef(doc, ref); err != nil {
|
||||
t.Fatalf("ValidateRef() error = %v, want nil", err)
|
||||
@@ -115,7 +115,7 @@ func TestParseRequestSourceIDOverridesMetadataIDs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseFallbackDocumentIDIsDeterministic(t *testing.T) {
|
||||
raw := []byte(`{"metadata":{},"segments":[{"id":"s1","start":0,"end":1,"speaker":"Narrator","text":"Synthetic text."}]}`)
|
||||
raw := []byte(`{"metadata":{},"segments":[{"id":1,"start":0,"end":1,"speaker":"Narrator","text":"Synthetic text."}]}`)
|
||||
|
||||
first, err := New().Parse(context.Background(), contracts.ParseRequest{Raw: raw})
|
||||
if err != nil {
|
||||
@@ -138,7 +138,7 @@ func TestParseFallbackDocumentIDIsDeterministic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseUsesMetadataSourceIDWhenMetadataIDIsAbsent(t *testing.T) {
|
||||
raw := []byte(`{"metadata":{"source_id":" source-from-metadata "},"segments":[{"id":"s1","start":0,"end":1,"speaker":"Narrator","text":"Synthetic text."}]}`)
|
||||
raw := []byte(`{"metadata":{"source_id":" source-from-metadata "},"segments":[{"id":1,"start":0,"end":1,"speaker":"Narrator","text":"Synthetic text."}]}`)
|
||||
|
||||
doc, err := New().Parse(context.Background(), contracts.ParseRequest{Raw: raw})
|
||||
if err != nil {
|
||||
@@ -203,7 +203,7 @@ func TestParseRejectsInvalidInput(t *testing.T) {
|
||||
{
|
||||
name: "missing segment id",
|
||||
raw: validJSONWithSegment(`"start":0,"end":1,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
wantErr: []string{"id", "empty"},
|
||||
wantErr: []string{"id", "positive"},
|
||||
},
|
||||
{
|
||||
name: "invalid segment id type",
|
||||
@@ -212,52 +212,52 @@ func TestParseRejectsInvalidInput(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "whitespace segment id",
|
||||
raw: validJSONWithSegment(`"id":" s1 ","start":0,"end":1,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
raw: validJSONWithSegment(`"id":" 1 ","start":0,"end":1,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
wantErr: []string{"id", "whitespace"},
|
||||
},
|
||||
{
|
||||
name: "empty text",
|
||||
raw: validJSONWithSegment(`"id":"s1","start":0,"end":1,"speaker":"Narrator","text":" "`),
|
||||
raw: validJSONWithSegment(`"id":1,"start":0,"end":1,"speaker":"Narrator","text":" "`),
|
||||
wantErr: []string{"text", "empty"},
|
||||
},
|
||||
{
|
||||
name: "missing speaker",
|
||||
raw: validJSONWithSegment(`"id":"s1","start":0,"end":1,"text":"Synthetic text."`),
|
||||
raw: validJSONWithSegment(`"id":1,"start":0,"end":1,"text":"Synthetic text."`),
|
||||
wantErr: []string{"speaker", "empty"},
|
||||
},
|
||||
{
|
||||
name: "missing start",
|
||||
raw: validJSONWithSegment(`"id":"s1","end":1,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
raw: validJSONWithSegment(`"id":1,"end":1,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
wantErr: []string{"start", "empty"},
|
||||
},
|
||||
{
|
||||
name: "missing end",
|
||||
raw: validJSONWithSegment(`"id":"s1","start":0,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
raw: validJSONWithSegment(`"id":1,"start":0,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
wantErr: []string{"end", "empty"},
|
||||
},
|
||||
{
|
||||
name: "negative start",
|
||||
raw: validJSONWithSegment(`"id":"s1","start":-1,"end":1,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
raw: validJSONWithSegment(`"id":1,"start":-1,"end":1,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
wantErr: []string{"start", "negative"},
|
||||
},
|
||||
{
|
||||
name: "non-numeric end",
|
||||
raw: validJSONWithSegment(`"id":"s1","start":0,"end":"late","speaker":"Narrator","text":"Synthetic text."`),
|
||||
raw: validJSONWithSegment(`"id":1,"start":0,"end":"late","speaker":"Narrator","text":"Synthetic text."`),
|
||||
wantErr: []string{"segment[0]", "end", "number"},
|
||||
},
|
||||
{
|
||||
name: "non-finite timestamp",
|
||||
raw: validJSONWithSegment(`"id":"s1","start":1e10000,"end":1e10000,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
raw: validJSONWithSegment(`"id":1,"start":1e10000,"end":1e10000,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
wantErr: []string{"start", "valid number"},
|
||||
},
|
||||
{
|
||||
name: "end before start",
|
||||
raw: validJSONWithSegment(`"id":"s1","start":2,"end":1,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
raw: validJSONWithSegment(`"id":1,"start":2,"end":1,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
wantErr: []string{"end", "start"},
|
||||
},
|
||||
{
|
||||
name: "end before start beyond float precision",
|
||||
raw: validJSONWithSegment(`"id":"s1","start":9007199254740993,"end":9007199254740992,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
raw: validJSONWithSegment(`"id":1,"start":9007199254740993,"end":9007199254740992,"speaker":"Narrator","text":"Synthetic text."`),
|
||||
wantErr: []string{"end", "start"},
|
||||
},
|
||||
}
|
||||
@@ -284,7 +284,7 @@ func TestParseRejectsDuplicateSegmentIDs(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("Parse() error = nil, want duplicate ID error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicated") || !strings.Contains(err.Error(), "seg-001") {
|
||||
if !strings.Contains(err.Error(), "duplicated") || !strings.Contains(err.Error(), "1") {
|
||||
t.Fatalf("Parse() error = %q, want duplicate segment context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type transcript struct {
|
||||
@@ -13,7 +15,7 @@ type transcript struct {
|
||||
}
|
||||
|
||||
type segment struct {
|
||||
ID string `json:"id"`
|
||||
ID int `json:"id"`
|
||||
Start json.Number `json:"start"`
|
||||
End json.Number `json:"end"`
|
||||
Speaker string `json:"speaker"`
|
||||
@@ -78,7 +80,7 @@ func decodeSegment(raw []byte, index int) (segment, error) {
|
||||
|
||||
var decoded segment
|
||||
if err := decodeOptionalSegmentID(fields, "id", &decoded.ID); err != nil {
|
||||
return segment{}, fmt.Errorf("segment[%d] id must be a string or number: %w", index, err)
|
||||
return segment{}, fmt.Errorf("segment[%d] id must be a positive integer string or number: %w", index, err)
|
||||
}
|
||||
if err := decodeOptionalNumber(fields, "start", &decoded.Start); err != nil {
|
||||
return segment{}, fmt.Errorf("segment[%d] start must be a number: %w", index, err)
|
||||
@@ -103,7 +105,7 @@ func decodeOptionalString(fields map[string]json.RawMessage, key string, out *st
|
||||
return decodeJSON(raw, out)
|
||||
}
|
||||
|
||||
func decodeOptionalSegmentID(fields map[string]json.RawMessage, key string, out *string) error {
|
||||
func decodeOptionalSegmentID(fields map[string]json.RawMessage, key string, out *int) error {
|
||||
raw, ok := fields[key]
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -111,17 +113,46 @@ func decodeOptionalSegmentID(fields map[string]json.RawMessage, key string, out
|
||||
|
||||
var text string
|
||||
if err := decodeJSON(raw, &text); err == nil {
|
||||
*out = text
|
||||
parsed, err := parsePositiveInt(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*out = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
var number json.Number
|
||||
if err := decodeJSON(raw, &number); err == nil {
|
||||
*out = number.String()
|
||||
parsed, err := parsePositiveInt(number.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*out = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("must be a string or number")
|
||||
return fmt.Errorf("must be a positive integer string or number")
|
||||
}
|
||||
|
||||
func parsePositiveInt(value string) (int, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 0, fmt.Errorf("must not be empty")
|
||||
}
|
||||
if trimmed != value {
|
||||
return 0, fmt.Errorf("must not contain leading or trailing whitespace")
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("must be an integer")
|
||||
}
|
||||
if parsed <= 0 {
|
||||
return 0, fmt.Errorf("must be positive")
|
||||
}
|
||||
if strconv.Itoa(parsed) != value {
|
||||
return 0, fmt.Errorf("must be a canonical positive integer")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func decodeOptionalNumber(fields map[string]json.RawMessage, key string, out *json.Number) error {
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestRunnerProcessesSeriatimInputWithFakeModules(t *testing.T) {
|
||||
if err := source.ValidateRef(expectedDoc, artifact.SourceRefs[0]); err != nil {
|
||||
t.Fatalf("ValidateRef() error = %v, want nil", err)
|
||||
}
|
||||
if artifact.SourceRefs[0].StartUnitID != "seg-001" || artifact.SourceRefs[0].EndUnitID != "seg-002" {
|
||||
if artifact.SourceRefs[0].StartUnitID != 1 || artifact.SourceRefs[0].EndUnitID != 2 {
|
||||
t.Fatalf("SourceRefs[0] = %#v, want Seriatim unit IDs", artifact.SourceRefs[0])
|
||||
}
|
||||
if extractor.calls != 1 {
|
||||
@@ -165,10 +165,14 @@ func (runnerSeriatimChunker) Chunk(ctx context.Context, req contracts.ChunkReque
|
||||
return contracts.ChunkResult{
|
||||
Chunks: []contracts.SourceChunk{
|
||||
{
|
||||
ID: req.Source.ID + ":chunk:0",
|
||||
SourceID: req.Source.ID,
|
||||
Index: 0,
|
||||
Units: append([]source.SourceUnit(nil), req.Source.Units...),
|
||||
ID: req.Source.ID + ":chunk:0",
|
||||
SourceID: req.Source.ID,
|
||||
Index: 0,
|
||||
StartUnitID: req.Source.Units[0].ID,
|
||||
EndUnitID: req.Source.Units[len(req.Source.Units)-1].ID,
|
||||
Content: []byte(`{"units":[1,2]}`),
|
||||
MediaType: "application/json",
|
||||
Units: append([]source.SourceUnit(nil), req.Source.Units...),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
@@ -206,21 +210,21 @@ func (e *runnerSeriatimExtractor) Extract(ctx context.Context, req contracts.Ext
|
||||
if req.Chunk == nil {
|
||||
return contracts.ExtractionResult{}, fmt.Errorf("chunk must not be nil")
|
||||
}
|
||||
if got := unitIDs(req.Source.Units); !equalStrings(got, []string{"seg-001", "seg-002"}) {
|
||||
if got := unitIDs(req.Source.Units); !equalInts(got, []int{1, 2}) {
|
||||
return contracts.ExtractionResult{}, fmt.Errorf("source unit IDs = %#v, want Seriatim segment IDs", got)
|
||||
}
|
||||
if got := unitIDs(req.Chunk.Units); !equalStrings(got, []string{"seg-001", "seg-002"}) {
|
||||
if got := unitIDs(req.Chunk.Units); !equalInts(got, []int{1, 2}) {
|
||||
return contracts.ExtractionResult{}, fmt.Errorf("chunk unit IDs = %#v, want Seriatim segment IDs", got)
|
||||
}
|
||||
for _, unit := range req.Chunk.Units {
|
||||
if speaker, ok := Speaker(unit); !ok || speaker == "" {
|
||||
return contracts.ExtractionResult{}, fmt.Errorf("unit %q missing speaker metadata", unit.ID)
|
||||
return contracts.ExtractionResult{}, fmt.Errorf("unit %d missing speaker metadata", unit.ID)
|
||||
}
|
||||
if _, ok := Start(unit); !ok {
|
||||
return contracts.ExtractionResult{}, fmt.Errorf("unit %q missing start metadata", unit.ID)
|
||||
return contracts.ExtractionResult{}, fmt.Errorf("unit %d missing start metadata", unit.ID)
|
||||
}
|
||||
if _, ok := End(unit); !ok {
|
||||
return contracts.ExtractionResult{}, fmt.Errorf("unit %q missing end metadata", unit.ID)
|
||||
return contracts.ExtractionResult{}, fmt.Errorf("unit %d missing end metadata", unit.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,15 +258,15 @@ func (runnerSeriatimOutput) Encode(ctx context.Context, req contracts.OutputRequ
|
||||
}, nil
|
||||
}
|
||||
|
||||
func unitIDs(units []source.SourceUnit) []string {
|
||||
ids := make([]string, 0, len(units))
|
||||
func unitIDs(units []source.SourceUnit) []int {
|
||||
ids := make([]int, 0, len(units))
|
||||
for _, unit := range units {
|
||||
ids = append(ids, unit.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
func equalInts(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
},
|
||||
"segments": [
|
||||
{
|
||||
"id": "seg-001",
|
||||
"id": 1,
|
||||
"start": 0,
|
||||
"end": 1,
|
||||
"speaker": "Narrator",
|
||||
"text": "First segment."
|
||||
},
|
||||
{
|
||||
"id": "seg-001",
|
||||
"id": 1,
|
||||
"start": 1,
|
||||
"end": 2,
|
||||
"speaker": "Player",
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
},
|
||||
"segments": [
|
||||
{
|
||||
"id": "seg-001",
|
||||
"id": 1,
|
||||
"start": 0,
|
||||
"end": 4.5,
|
||||
"speaker": "Narrator",
|
||||
"text": "The stone door opens."
|
||||
},
|
||||
{
|
||||
"id": "seg-002",
|
||||
"id": 2,
|
||||
"start": 4.5,
|
||||
"end": 8,
|
||||
"speaker": "Player",
|
||||
|
||||
Reference in New Issue
Block a user