240 lines
5.8 KiB
Go
240 lines
5.8 KiB
Go
package schema
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
)
|
|
|
|
type SourceSegment struct {
|
|
ID *int `json:"id,omitempty"`
|
|
Speaker string `json:"speaker"`
|
|
Start float64 `json:"start"`
|
|
End float64 `json:"end"`
|
|
Text string `json:"text"`
|
|
Categories []string `json:"categories,omitempty"`
|
|
}
|
|
|
|
type Segment struct {
|
|
ID int `json:"id"`
|
|
Speaker string `json:"speaker"`
|
|
Start float64 `json:"start"`
|
|
End float64 `json:"end"`
|
|
Text string `json:"text"`
|
|
Categories []string `json:"categories,omitempty"`
|
|
}
|
|
|
|
type Transcript struct {
|
|
Segments []Segment `json:"segments"`
|
|
}
|
|
|
|
type SourceTranscript struct {
|
|
Segments []SourceSegment `json:"segments"`
|
|
}
|
|
|
|
func ParseSourceTranscriptJSON(raw []byte) (*SourceTranscript, error) {
|
|
if !json.Valid(raw) {
|
|
return nil, &ParseError{Message: "transcript is not valid JSON"}
|
|
}
|
|
|
|
var top any
|
|
if err := json.Unmarshal(raw, &top); err != nil {
|
|
return nil, &ParseError{Message: fmt.Sprintf("failed to parse transcript JSON: %v", err)}
|
|
}
|
|
|
|
segmentsRaw, err := extractSegments(top)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var segments []SourceSegment
|
|
if err := json.Unmarshal(segmentsRaw, &segments); err != nil {
|
|
return nil, &ParseError{Message: fmt.Sprintf("failed to parse segments: %v", err)}
|
|
}
|
|
if len(segments) == 0 {
|
|
return nil, &ParseError{Message: "transcript must contain at least one segment"}
|
|
}
|
|
|
|
if err := validateSourceSegments(segments); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &SourceTranscript{Segments: segments}, nil
|
|
}
|
|
|
|
func ParseTranscriptJSON(raw []byte) (*Transcript, error) {
|
|
source, err := ParseSourceTranscriptJSON(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
segments := make([]Segment, len(source.Segments))
|
|
for i, s := range source.Segments {
|
|
segments[i] = Segment{
|
|
ID: *s.ID,
|
|
Speaker: s.Speaker,
|
|
Start: s.Start,
|
|
End: s.End,
|
|
Text: s.Text,
|
|
Categories: s.Categories,
|
|
}
|
|
}
|
|
|
|
if err := validateSequentialIDs(segments); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Transcript{Segments: segments}, nil
|
|
}
|
|
|
|
func ParseTranscriptJSONLenient(raw []byte) (*Transcript, error) {
|
|
source, err := ParseSourceTranscriptJSON(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
segments := make([]Segment, len(source.Segments))
|
|
for i, s := range source.Segments {
|
|
id := i + 1
|
|
if s.ID != nil {
|
|
id = *s.ID
|
|
}
|
|
segments[i] = Segment{
|
|
ID: id,
|
|
Speaker: s.Speaker,
|
|
Start: s.Start,
|
|
End: s.End,
|
|
Text: s.Text,
|
|
Categories: s.Categories,
|
|
}
|
|
}
|
|
|
|
return &Transcript{Segments: segments}, nil
|
|
}
|
|
|
|
func extractSegments(top any) (json.RawMessage, error) {
|
|
switch v := top.(type) {
|
|
case []any:
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, &ParseError{Message: "failed to re-encode segment array"}
|
|
}
|
|
return raw, nil
|
|
case map[string]any:
|
|
segs, ok := v["segments"]
|
|
if !ok {
|
|
return nil, &ParseError{Message: "transcript object must contain a segments array"}
|
|
}
|
|
segsArray, ok := segs.([]any)
|
|
if !ok {
|
|
return nil, &ParseError{Message: "segments field must be an array"}
|
|
}
|
|
raw, err := json.Marshal(segsArray)
|
|
if err != nil {
|
|
return nil, &ParseError{Message: "failed to re-encode segments array"}
|
|
}
|
|
return raw, nil
|
|
default:
|
|
return nil, &ParseError{Message: "transcript must be a JSON array or an object with a segments array"}
|
|
}
|
|
}
|
|
|
|
func validateSourceSegments(segments []SourceSegment) error {
|
|
for i, s := range segments {
|
|
segLabel := segmentLabel(i, s.ID)
|
|
|
|
if s.Speaker == "" {
|
|
return &ValidationError{Field: fmt.Sprintf("%s.speaker", segLabel), Message: "must not be empty"}
|
|
}
|
|
|
|
if s.Text == "" {
|
|
return &ValidationError{Field: fmt.Sprintf("%s.text", segLabel), Message: "must not be empty"}
|
|
}
|
|
|
|
if err := validateTime(s.Start, fmt.Sprintf("%s.start", segLabel)); err != nil {
|
|
return err
|
|
}
|
|
if err := validateTime(s.End, fmt.Sprintf("%s.end", segLabel)); err != nil {
|
|
return err
|
|
}
|
|
|
|
if s.End < s.Start {
|
|
return &ValidationError{
|
|
Field: fmt.Sprintf("%s.end", segLabel),
|
|
Message: fmt.Sprintf("end (%g) must be greater than or equal to start (%g)", s.End, s.Start),
|
|
}
|
|
}
|
|
|
|
for j, cat := range s.Categories {
|
|
if cat == "" {
|
|
return &ValidationError{
|
|
Field: fmt.Sprintf("%s.categories[%d]", segLabel, j),
|
|
Message: "must not be empty",
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
seenIDs := make(map[int]int)
|
|
for i, s := range segments {
|
|
if s.ID != nil {
|
|
if firstIdx, exists := seenIDs[*s.ID]; exists {
|
|
return &ValidationError{
|
|
Field: fmt.Sprintf("segment[%d].id", i),
|
|
Message: fmt.Sprintf("duplicate id %d (first used at segment[%d])", *s.ID, firstIdx),
|
|
}
|
|
}
|
|
seenIDs[*s.ID] = i
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateTime(t float64, field string) error {
|
|
if math.IsNaN(t) || math.IsInf(t, 0) {
|
|
return &ValidationError{Field: field, Message: "must be a finite number"}
|
|
}
|
|
if t < 0 {
|
|
return &ValidationError{Field: field, Message: "must be non-negative"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSequentialIDs(segments []Segment) error {
|
|
for i, s := range segments {
|
|
expected := i + 1
|
|
if s.ID != expected {
|
|
return &ValidationError{
|
|
Field: fmt.Sprintf("segment[%d].id", i),
|
|
Message: fmt.Sprintf("must be sequential starting at 1 (got %d, expected %d)", s.ID, expected),
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func segmentLabel(index int, id *int) string {
|
|
if id != nil {
|
|
return fmt.Sprintf("segment[%d] (id=%d)", index, *id)
|
|
}
|
|
return fmt.Sprintf("segment[%d]", index)
|
|
}
|
|
|
|
func TranscriptToJSON(t *Transcript) ([]byte, error) {
|
|
payload := make([]map[string]any, len(t.Segments))
|
|
for i, s := range t.Segments {
|
|
payload[i] = map[string]any{
|
|
"id": s.ID,
|
|
"speaker": s.Speaker,
|
|
"start": s.Start,
|
|
"end": s.End,
|
|
"text": s.Text,
|
|
}
|
|
if len(s.Categories) > 0 {
|
|
payload[i]["categories"] = s.Categories
|
|
}
|
|
}
|
|
return json.MarshalIndent(payload, "", " ")
|
|
}
|