Implement Seriatim subprocess adapter
This commit is contained in:
@@ -18,7 +18,15 @@ func (n *NoopRunner) Run(ctx context.Context, req MergeRequest) (MergeResult, er
|
|||||||
if err := materializePlaceholders(req); err != nil {
|
if err := materializePlaceholders(req); err != nil {
|
||||||
return MergeResult{}, err
|
return MergeResult{}, err
|
||||||
}
|
}
|
||||||
return MergeResult{MergedTranscriptPath: req.OutputMergedTranscriptPath, Metadata: map[string]any{"placeholder": true}}, nil
|
return MergeResult{
|
||||||
|
MergedTranscriptPath: req.OutputMergedTranscriptPath,
|
||||||
|
ReportPath: req.ReportPath,
|
||||||
|
StdoutLogPath: req.StdoutLogPath,
|
||||||
|
StderrLogPath: req.StderrLogPath,
|
||||||
|
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||||
|
InvokedBinary: "noop",
|
||||||
|
Metadata: map[string]any{"placeholder": true},
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FakeRunner captures merge requests and returns deterministic responses.
|
// FakeRunner captures merge requests and returns deterministic responses.
|
||||||
@@ -44,6 +52,21 @@ func (f *FakeRunner) Run(ctx context.Context, req MergeRequest) (MergeResult, er
|
|||||||
if res.MergedTranscriptPath == "" {
|
if res.MergedTranscriptPath == "" {
|
||||||
res.MergedTranscriptPath = req.OutputMergedTranscriptPath
|
res.MergedTranscriptPath = req.OutputMergedTranscriptPath
|
||||||
}
|
}
|
||||||
|
if res.ReportPath == "" {
|
||||||
|
res.ReportPath = req.ReportPath
|
||||||
|
}
|
||||||
|
if res.StdoutLogPath == "" {
|
||||||
|
res.StdoutLogPath = req.StdoutLogPath
|
||||||
|
}
|
||||||
|
if res.StderrLogPath == "" {
|
||||||
|
res.StderrLogPath = req.StderrLogPath
|
||||||
|
}
|
||||||
|
if res.GeneratedConfigPath == "" {
|
||||||
|
res.GeneratedConfigPath = req.GeneratedConfigPath
|
||||||
|
}
|
||||||
|
if res.InvokedBinary == "" {
|
||||||
|
res.InvokedBinary = "fake"
|
||||||
|
}
|
||||||
if res.Metadata == nil {
|
if res.Metadata == nil {
|
||||||
res.Metadata = map[string]any{"fake": true}
|
res.Metadata = map[string]any{"fake": true}
|
||||||
}
|
}
|
||||||
@@ -72,5 +95,10 @@ func materializePlaceholders(req MergeRequest) error {
|
|||||||
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if req.ReportPath != "" {
|
||||||
|
if err := subprocess.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write report %q: %w", req.ReportPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// Package seriatim declares the adapter contract for transcript merge execution.
|
// Package seriatim declares the adapter contract for transcript merge execution.
|
||||||
package seriatim
|
package seriatim
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// TODO: implement a real Seriatim subprocess adapter.
|
// TODO: implement a real Seriatim subprocess adapter.
|
||||||
|
|
||||||
@@ -15,6 +18,9 @@ type MergeRequest struct {
|
|||||||
GeneratedConfigPath string
|
GeneratedConfigPath string
|
||||||
InputTranscriptPaths []string
|
InputTranscriptPaths []string
|
||||||
OutputMergedTranscriptPath string
|
OutputMergedTranscriptPath string
|
||||||
|
ReportPath string
|
||||||
|
SpeakersPath string
|
||||||
|
AutocorrectPath string
|
||||||
StdoutLogPath string
|
StdoutLogPath string
|
||||||
StderrLogPath string
|
StderrLogPath string
|
||||||
}
|
}
|
||||||
@@ -22,5 +28,13 @@ type MergeRequest struct {
|
|||||||
// MergeResult describes a merge output.
|
// MergeResult describes a merge output.
|
||||||
type MergeResult struct {
|
type MergeResult struct {
|
||||||
MergedTranscriptPath string
|
MergedTranscriptPath string
|
||||||
|
ReportPath string
|
||||||
|
StdoutLogPath string
|
||||||
|
StderrLogPath string
|
||||||
|
GeneratedConfigPath string
|
||||||
|
ExitCode int
|
||||||
|
Duration time.Duration
|
||||||
|
InvokedBinary string
|
||||||
|
OutputSchema string
|
||||||
Metadata map[string]any
|
Metadata map[string]any
|
||||||
}
|
}
|
||||||
|
|||||||
274
internal/adapters/seriatim/subprocess.go
Normal file
274
internal/adapters/seriatim/subprocess.go
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
package seriatim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnvConfig defines optional Seriatim environment tuning values.
|
||||||
|
type EnvConfig struct {
|
||||||
|
OverlapWordRunGap *float64
|
||||||
|
OverlapWordRunReorderWindow *float64
|
||||||
|
BackchannelMaxDuration *float64
|
||||||
|
FillerMaxDuration *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubprocessRunnerConfig defines deterministic settings for Seriatim CLI execution.
|
||||||
|
type SubprocessRunnerConfig struct {
|
||||||
|
Binary string
|
||||||
|
Timeout time.Duration
|
||||||
|
OutputSchema string
|
||||||
|
CoalesceGap *float64
|
||||||
|
Report bool
|
||||||
|
Env EnvConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubprocessRunner invokes Seriatim via subprocess.
|
||||||
|
type SubprocessRunner struct {
|
||||||
|
binary string
|
||||||
|
timeout time.Duration
|
||||||
|
outputSchema string
|
||||||
|
coalesceGap *float64
|
||||||
|
report bool
|
||||||
|
env EnvConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSubprocessRunnerFromConfigValues parses config-derived values once.
|
||||||
|
func NewSubprocessRunnerFromConfigValues(
|
||||||
|
binary string,
|
||||||
|
timeout string,
|
||||||
|
outputSchema string,
|
||||||
|
coalesceGap *float64,
|
||||||
|
report bool,
|
||||||
|
env EnvConfig,
|
||||||
|
) (*SubprocessRunner, error) {
|
||||||
|
if strings.TrimSpace(timeout) == "" {
|
||||||
|
return nil, fmt.Errorf("seriatim timeout is required")
|
||||||
|
}
|
||||||
|
parsedTimeout, err := time.ParseDuration(timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse seriatim timeout %q: %w", timeout, err)
|
||||||
|
}
|
||||||
|
return NewSubprocessRunner(SubprocessRunnerConfig{
|
||||||
|
Binary: binary,
|
||||||
|
Timeout: parsedTimeout,
|
||||||
|
OutputSchema: outputSchema,
|
||||||
|
CoalesceGap: coalesceGap,
|
||||||
|
Report: report,
|
||||||
|
Env: env,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSubprocessRunner constructs a validated Seriatim subprocess runner.
|
||||||
|
func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error) {
|
||||||
|
if strings.TrimSpace(cfg.Binary) == "" {
|
||||||
|
return nil, fmt.Errorf("seriatim binary is required")
|
||||||
|
}
|
||||||
|
if cfg.Timeout <= 0 {
|
||||||
|
return nil, fmt.Errorf("seriatim timeout must be > 0")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.OutputSchema) == "" {
|
||||||
|
return nil, fmt.Errorf("seriatim output schema is required")
|
||||||
|
}
|
||||||
|
switch cfg.OutputSchema {
|
||||||
|
case "seriatim-minimal", "seriatim-intermediate", "seriatim-full":
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("seriatim output schema %q is unsupported", cfg.OutputSchema)
|
||||||
|
}
|
||||||
|
if cfg.CoalesceGap != nil && *cfg.CoalesceGap < 0 {
|
||||||
|
return nil, fmt.Errorf("seriatim coalesce gap must be >= 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SubprocessRunner{
|
||||||
|
binary: cfg.Binary,
|
||||||
|
timeout: cfg.Timeout,
|
||||||
|
outputSchema: cfg.OutputSchema,
|
||||||
|
coalesceGap: cfg.CoalesceGap,
|
||||||
|
report: cfg.Report,
|
||||||
|
env: cfg.Env,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes Seriatim merge with deterministic flags and validates output artifacts.
|
||||||
|
func (r *SubprocessRunner) Run(ctx context.Context, req MergeRequest) (MergeResult, error) {
|
||||||
|
if r == nil {
|
||||||
|
return MergeResult{}, fmt.Errorf("seriatim subprocess runner is nil")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.OutputMergedTranscriptPath) == "" {
|
||||||
|
return MergeResult{}, fmt.Errorf("seriatim merge output path is required")
|
||||||
|
}
|
||||||
|
if len(req.InputTranscriptPaths) == 0 {
|
||||||
|
return MergeResult{}, fmt.Errorf("seriatim merge requires at least one input transcript")
|
||||||
|
}
|
||||||
|
if r.report && strings.TrimSpace(req.ReportPath) == "" {
|
||||||
|
return MergeResult{}, fmt.Errorf("seriatim report is enabled but report path is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
args := r.buildArgs(req)
|
||||||
|
env := r.buildEnvOverrides()
|
||||||
|
|
||||||
|
if req.GeneratedConfigPath != "" {
|
||||||
|
if err := r.writeInvocationConfig(req, args); err != nil {
|
||||||
|
return MergeResult{}, fmt.Errorf("write seriatim invocation config %q: %w", req.GeneratedConfigPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runRes, err := subprocess.Run(ctx, subprocess.RunRequest{
|
||||||
|
Executable: r.binary,
|
||||||
|
Args: args,
|
||||||
|
Timeout: r.timeout,
|
||||||
|
EnvOverrides: env,
|
||||||
|
StdoutLogPath: req.StdoutLogPath,
|
||||||
|
StderrLogPath: req.StderrLogPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return MergeResult{
|
||||||
|
MergedTranscriptPath: req.OutputMergedTranscriptPath,
|
||||||
|
ReportPath: req.ReportPath,
|
||||||
|
StdoutLogPath: req.StdoutLogPath,
|
||||||
|
StderrLogPath: req.StderrLogPath,
|
||||||
|
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||||
|
ExitCode: runRes.ExitCode,
|
||||||
|
Duration: runRes.Duration,
|
||||||
|
InvokedBinary: r.binary,
|
||||||
|
OutputSchema: r.outputSchema,
|
||||||
|
}, fmt.Errorf("run seriatim merge (binary=%q): %w", r.binary, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateJSONFile(req.OutputMergedTranscriptPath); err != nil {
|
||||||
|
return MergeResult{
|
||||||
|
MergedTranscriptPath: req.OutputMergedTranscriptPath,
|
||||||
|
ReportPath: req.ReportPath,
|
||||||
|
StdoutLogPath: req.StdoutLogPath,
|
||||||
|
StderrLogPath: req.StderrLogPath,
|
||||||
|
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||||
|
ExitCode: runRes.ExitCode,
|
||||||
|
Duration: runRes.Duration,
|
||||||
|
InvokedBinary: r.binary,
|
||||||
|
OutputSchema: r.outputSchema,
|
||||||
|
}, fmt.Errorf("validate seriatim merged output %q: %w", req.OutputMergedTranscriptPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.report {
|
||||||
|
if err := validateJSONFile(req.ReportPath); err != nil {
|
||||||
|
return MergeResult{
|
||||||
|
MergedTranscriptPath: req.OutputMergedTranscriptPath,
|
||||||
|
ReportPath: req.ReportPath,
|
||||||
|
StdoutLogPath: req.StdoutLogPath,
|
||||||
|
StderrLogPath: req.StderrLogPath,
|
||||||
|
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||||
|
ExitCode: runRes.ExitCode,
|
||||||
|
Duration: runRes.Duration,
|
||||||
|
InvokedBinary: r.binary,
|
||||||
|
OutputSchema: r.outputSchema,
|
||||||
|
}, fmt.Errorf("validate seriatim report output %q: %w", req.ReportPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MergeResult{
|
||||||
|
MergedTranscriptPath: req.OutputMergedTranscriptPath,
|
||||||
|
ReportPath: req.ReportPath,
|
||||||
|
StdoutLogPath: req.StdoutLogPath,
|
||||||
|
StderrLogPath: req.StderrLogPath,
|
||||||
|
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||||
|
ExitCode: runRes.ExitCode,
|
||||||
|
Duration: runRes.Duration,
|
||||||
|
InvokedBinary: r.binary,
|
||||||
|
OutputSchema: r.outputSchema,
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"adapter": "seriatim_subprocess",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SubprocessRunner) buildArgs(req MergeRequest) []string {
|
||||||
|
args := []string{"merge"}
|
||||||
|
|
||||||
|
for _, path := range req.InputTranscriptPaths {
|
||||||
|
args = append(args, "--input-file", path)
|
||||||
|
}
|
||||||
|
args = append(args, "--output-file", req.OutputMergedTranscriptPath)
|
||||||
|
|
||||||
|
if r.report {
|
||||||
|
args = append(args, "--report-file", req.ReportPath)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.SpeakersPath) != "" {
|
||||||
|
args = append(args, "--speakers", req.SpeakersPath)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.AutocorrectPath) != "" {
|
||||||
|
args = append(args, "--autocorrect", req.AutocorrectPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "--output-schema", r.outputSchema)
|
||||||
|
if r.coalesceGap != nil {
|
||||||
|
args = append(args, "--coalesce-gap", strconv.FormatFloat(*r.coalesceGap, 'f', -1, 64))
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SubprocessRunner) buildEnvOverrides() map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
if r.env.OverlapWordRunGap != nil {
|
||||||
|
out["SERIATIM_OVERLAP_WORD_RUN_GAP"] = strconv.FormatFloat(*r.env.OverlapWordRunGap, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
if r.env.OverlapWordRunReorderWindow != nil {
|
||||||
|
out["SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"] = strconv.FormatFloat(*r.env.OverlapWordRunReorderWindow, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
if r.env.BackchannelMaxDuration != nil {
|
||||||
|
out["SERIATIM_BACKCHANNEL_MAX_DURATION"] = strconv.FormatFloat(*r.env.BackchannelMaxDuration, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
if r.env.FillerMaxDuration != nil {
|
||||||
|
out["SERIATIM_FILLER_MAX_DURATION"] = strconv.FormatFloat(*r.env.FillerMaxDuration, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SubprocessRunner) writeInvocationConfig(req MergeRequest, args []string) error {
|
||||||
|
payload := map[string]any{
|
||||||
|
"schema": "seriatim.generated.v1",
|
||||||
|
"binary": r.binary,
|
||||||
|
"args": args,
|
||||||
|
"timeout": r.timeout.String(),
|
||||||
|
"output_schema": r.outputSchema,
|
||||||
|
"report_enabled": r.report,
|
||||||
|
"input_transcript_paths": req.InputTranscriptPaths,
|
||||||
|
"output_path": req.OutputMergedTranscriptPath,
|
||||||
|
}
|
||||||
|
if req.ReportPath != "" {
|
||||||
|
payload["report_path"] = req.ReportPath
|
||||||
|
}
|
||||||
|
if req.SpeakersPath != "" {
|
||||||
|
payload["speakers_path"] = req.SpeakersPath
|
||||||
|
}
|
||||||
|
if req.AutocorrectPath != "" {
|
||||||
|
payload["autocorrect_path"] = req.AutocorrectPath
|
||||||
|
}
|
||||||
|
if r.coalesceGap != nil {
|
||||||
|
payload["coalesce_gap"] = *r.coalesceGap
|
||||||
|
}
|
||||||
|
|
||||||
|
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateJSONFile(path string) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read file: %w", err)
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
return fmt.Errorf("parse json: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
418
internal/adapters/seriatim/subprocess_test.go
Normal file
418
internal/adapters/seriatim/subprocess_test.go
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
package seriatim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubprocessRunnerSuccessWithReportArgsAndEnv(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("helper wrapper script uses /bin/sh")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
|
t.Setenv("SERIATIM_HELPER_MODE", "success")
|
||||||
|
|
||||||
|
recordPath := filepath.Join(t.TempDir(), "record.json")
|
||||||
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath)
|
||||||
|
|
||||||
|
wrapper := writeHelperWrapper(t)
|
||||||
|
coalesce := 3.0
|
||||||
|
owg := 1.0
|
||||||
|
owrw := 1.0
|
||||||
|
bmd := 2.0
|
||||||
|
fmd := 1.25
|
||||||
|
runner, err := NewSubprocessRunner(SubprocessRunnerConfig{
|
||||||
|
Binary: wrapper,
|
||||||
|
Timeout: mustParseDuration(t, "2s"),
|
||||||
|
OutputSchema: "seriatim-intermediate",
|
||||||
|
CoalesceGap: &coalesce,
|
||||||
|
Report: true,
|
||||||
|
Env: EnvConfig{
|
||||||
|
OverlapWordRunGap: &owg,
|
||||||
|
OverlapWordRunReorderWindow: &owrw,
|
||||||
|
BackchannelMaxDuration: &bmd,
|
||||||
|
FillerMaxDuration: &fmd,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSubprocessRunner() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
req := MergeRequest{
|
||||||
|
GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"),
|
||||||
|
InputTranscriptPaths: []string{filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json")},
|
||||||
|
OutputMergedTranscriptPath: filepath.Join(dir, "merged.json"),
|
||||||
|
ReportPath: filepath.Join(dir, "seriatim.report.json"),
|
||||||
|
SpeakersPath: filepath.Join(dir, "speakers.yml"),
|
||||||
|
AutocorrectPath: filepath.Join(dir, "autocorrect.yml"),
|
||||||
|
StdoutLogPath: filepath.Join(dir, "seriatim.stdout.log"),
|
||||||
|
StderrLogPath: filepath.Join(dir, "seriatim.stderr.log"),
|
||||||
|
}
|
||||||
|
writeSeriatimFile(t, req.InputTranscriptPaths[0], `{"a":1}`)
|
||||||
|
writeSeriatimFile(t, req.InputTranscriptPaths[1], `{"b":1}`)
|
||||||
|
writeSeriatimFile(t, req.SpeakersPath, "speakers: []\n")
|
||||||
|
writeSeriatimFile(t, req.AutocorrectPath, "autocorrect: []\n")
|
||||||
|
|
||||||
|
res, err := runner.Run(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if res.MergedTranscriptPath != req.OutputMergedTranscriptPath {
|
||||||
|
t.Fatalf("MergedTranscriptPath = %q, want %q", res.MergedTranscriptPath, req.OutputMergedTranscriptPath)
|
||||||
|
}
|
||||||
|
if res.ReportPath != req.ReportPath {
|
||||||
|
t.Fatalf("ReportPath = %q, want %q", res.ReportPath, req.ReportPath)
|
||||||
|
}
|
||||||
|
if res.OutputSchema != "seriatim-intermediate" {
|
||||||
|
t.Fatalf("OutputSchema = %q, want seriatim-intermediate", res.OutputSchema)
|
||||||
|
}
|
||||||
|
if res.InvokedBinary != wrapper {
|
||||||
|
t.Fatalf("InvokedBinary = %q, want %q", res.InvokedBinary, wrapper)
|
||||||
|
}
|
||||||
|
if res.ExitCode != 0 {
|
||||||
|
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
|
||||||
|
}
|
||||||
|
if res.Duration <= 0 {
|
||||||
|
t.Fatalf("Duration = %s, want >0", res.Duration)
|
||||||
|
}
|
||||||
|
if res.Metadata == nil || res.Metadata["adapter"] != "seriatim_subprocess" {
|
||||||
|
t.Fatalf("Metadata = %#v, want adapter marker", res.Metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertJSONFile(t, req.OutputMergedTranscriptPath)
|
||||||
|
assertJSONFile(t, req.ReportPath)
|
||||||
|
|
||||||
|
if _, err := os.Stat(req.GeneratedConfigPath); err != nil {
|
||||||
|
t.Fatalf("generated config missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(req.StdoutLogPath); err != nil {
|
||||||
|
t.Fatalf("stdout log missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(req.StderrLogPath); err != nil {
|
||||||
|
t.Fatalf("stderr log missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := readHelperRecord(t, recordPath)
|
||||||
|
wantArgs := []string{
|
||||||
|
"merge",
|
||||||
|
"--input-file", req.InputTranscriptPaths[0],
|
||||||
|
"--input-file", req.InputTranscriptPaths[1],
|
||||||
|
"--output-file", req.OutputMergedTranscriptPath,
|
||||||
|
"--report-file", req.ReportPath,
|
||||||
|
"--speakers", req.SpeakersPath,
|
||||||
|
"--autocorrect", req.AutocorrectPath,
|
||||||
|
"--output-schema", "seriatim-intermediate",
|
||||||
|
"--coalesce-gap", "3",
|
||||||
|
}
|
||||||
|
if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") {
|
||||||
|
t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs)
|
||||||
|
}
|
||||||
|
if rec.Env["SERIATIM_OVERLAP_WORD_RUN_GAP"] != "1" {
|
||||||
|
t.Fatalf("SERIATIM_OVERLAP_WORD_RUN_GAP = %q, want 1", rec.Env["SERIATIM_OVERLAP_WORD_RUN_GAP"])
|
||||||
|
}
|
||||||
|
if rec.Env["SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"] != "1" {
|
||||||
|
t.Fatalf("SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW = %q, want 1", rec.Env["SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"])
|
||||||
|
}
|
||||||
|
if rec.Env["SERIATIM_BACKCHANNEL_MAX_DURATION"] != "2" {
|
||||||
|
t.Fatalf("SERIATIM_BACKCHANNEL_MAX_DURATION = %q, want 2", rec.Env["SERIATIM_BACKCHANNEL_MAX_DURATION"])
|
||||||
|
}
|
||||||
|
if rec.Env["SERIATIM_FILLER_MAX_DURATION"] != "1.25" {
|
||||||
|
t.Fatalf("SERIATIM_FILLER_MAX_DURATION = %q, want 1.25", rec.Env["SERIATIM_FILLER_MAX_DURATION"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("helper wrapper script uses /bin/sh")
|
||||||
|
}
|
||||||
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
|
t.Setenv("SERIATIM_HELPER_MODE", "fail")
|
||||||
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|
||||||
|
runner := mustRunner(t, writeHelperWrapper(t), false)
|
||||||
|
req := mergeReqForTest(t, false)
|
||||||
|
_, err := runner.Run(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "run seriatim merge") {
|
||||||
|
t.Fatalf("error = %q, want subprocess context", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "exit code") {
|
||||||
|
t.Fatalf("error = %q, want exit code context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerMissingOutputFails(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("helper wrapper script uses /bin/sh")
|
||||||
|
}
|
||||||
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
|
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
|
||||||
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|
||||||
|
runner := mustRunner(t, writeHelperWrapper(t), false)
|
||||||
|
req := mergeReqForTest(t, false)
|
||||||
|
_, err := runner.Run(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "validate seriatim merged output") {
|
||||||
|
t.Fatalf("error = %q, want output validation context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("helper wrapper script uses /bin/sh")
|
||||||
|
}
|
||||||
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
|
t.Setenv("SERIATIM_HELPER_MODE", "invalid_output")
|
||||||
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|
||||||
|
runner := mustRunner(t, writeHelperWrapper(t), false)
|
||||||
|
req := mergeReqForTest(t, false)
|
||||||
|
_, err := runner.Run(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "parse json") {
|
||||||
|
t.Fatalf("error = %q, want parse json context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("helper wrapper script uses /bin/sh")
|
||||||
|
}
|
||||||
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
|
t.Setenv("SERIATIM_HELPER_MODE", "invalid_report")
|
||||||
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|
||||||
|
runner := mustRunner(t, writeHelperWrapper(t), true)
|
||||||
|
req := mergeReqForTest(t, true)
|
||||||
|
_, err := runner.Run(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "validate seriatim report output") {
|
||||||
|
t.Fatalf("error = %q, want report validation context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerConstructorValidation(t *testing.T) {
|
||||||
|
_, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected binary validation error")
|
||||||
|
}
|
||||||
|
_, err = NewSubprocessRunnerFromConfigValues("seriatim", "bad", "seriatim-intermediate", nil, true, EnvConfig{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected timeout parse error")
|
||||||
|
}
|
||||||
|
_, err = NewSubprocessRunner(SubprocessRunnerConfig{
|
||||||
|
Binary: "seriatim",
|
||||||
|
Timeout: mustParseDuration(t, "1s"),
|
||||||
|
OutputSchema: "bad-schema",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected output schema validation error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type helperRecord struct {
|
||||||
|
Args []string `json:"args"`
|
||||||
|
Env map[string]string `json:"env"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeriatimSubprocessHelper(t *testing.T) {
|
||||||
|
if os.Getenv("GO_WANT_SERIATIM_HELPER") != "1" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
args := os.Args
|
||||||
|
start := -1
|
||||||
|
for i := range args {
|
||||||
|
if args[i] == "--" {
|
||||||
|
start = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start < 0 || start >= len(args) {
|
||||||
|
_, _ = os.Stderr.WriteString("missing -- args separator\n")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
mergeArgs := args[start:]
|
||||||
|
|
||||||
|
outputPath := flagValue(mergeArgs, "--output-file")
|
||||||
|
reportPath := flagValue(mergeArgs, "--report-file")
|
||||||
|
recordPath := os.Getenv("SERIATIM_HELPER_RECORD_PATH")
|
||||||
|
if strings.TrimSpace(recordPath) != "" {
|
||||||
|
rec := helperRecord{
|
||||||
|
Args: mergeArgs,
|
||||||
|
Env: map[string]string{
|
||||||
|
"SERIATIM_OVERLAP_WORD_RUN_GAP": os.Getenv("SERIATIM_OVERLAP_WORD_RUN_GAP"),
|
||||||
|
"SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW": os.Getenv("SERIATIM_OVERLAP_WORD_RUN_REORDER_WINDOW"),
|
||||||
|
"SERIATIM_BACKCHANNEL_MAX_DURATION": os.Getenv("SERIATIM_BACKCHANNEL_MAX_DURATION"),
|
||||||
|
"SERIATIM_FILLER_MAX_DURATION": os.Getenv("SERIATIM_FILLER_MAX_DURATION"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(rec)
|
||||||
|
_ = os.MkdirAll(filepath.Dir(recordPath), 0o755)
|
||||||
|
_ = os.WriteFile(recordPath, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := os.Getenv("SERIATIM_HELPER_MODE")
|
||||||
|
switch mode {
|
||||||
|
case "success":
|
||||||
|
writeSeriatimHelperFile(outputPath, `{"merged":true}`)
|
||||||
|
if reportPath != "" {
|
||||||
|
writeSeriatimHelperFile(reportPath, `{"report":true}`)
|
||||||
|
}
|
||||||
|
_, _ = os.Stdout.WriteString("seriatim helper success stdout\n")
|
||||||
|
_, _ = os.Stderr.WriteString("seriatim helper success stderr\n")
|
||||||
|
os.Exit(0)
|
||||||
|
case "fail":
|
||||||
|
_, _ = os.Stderr.WriteString("seriatim helper failure\n")
|
||||||
|
os.Exit(9)
|
||||||
|
case "missing_output":
|
||||||
|
if reportPath != "" {
|
||||||
|
writeSeriatimHelperFile(reportPath, `{"report":true}`)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
case "invalid_output":
|
||||||
|
writeSeriatimHelperFile(outputPath, `not-json`)
|
||||||
|
if reportPath != "" {
|
||||||
|
writeSeriatimHelperFile(reportPath, `{"report":true}`)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
case "invalid_report":
|
||||||
|
writeSeriatimHelperFile(outputPath, `{"merged":true}`)
|
||||||
|
if reportPath != "" {
|
||||||
|
writeSeriatimHelperFile(reportPath, `not-json`)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
default:
|
||||||
|
_, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode))
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHelperWrapper(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.Executable() error = %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "seriatim-helper-wrapper.sh")
|
||||||
|
content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestSeriatimSubprocessHelper -- \"$@\"\n"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeReqForTest(t *testing.T, withReport bool) MergeRequest {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
in1 := filepath.Join(dir, "a.json")
|
||||||
|
in2 := filepath.Join(dir, "b.json")
|
||||||
|
writeSeriatimFile(t, in1, `{"a":1}`)
|
||||||
|
writeSeriatimFile(t, in2, `{"b":1}`)
|
||||||
|
req := MergeRequest{
|
||||||
|
GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"),
|
||||||
|
InputTranscriptPaths: []string{in1, in2},
|
||||||
|
OutputMergedTranscriptPath: filepath.Join(dir, "merged.json"),
|
||||||
|
StdoutLogPath: filepath.Join(dir, "seriatim.stdout.log"),
|
||||||
|
StderrLogPath: filepath.Join(dir, "seriatim.stderr.log"),
|
||||||
|
}
|
||||||
|
if withReport {
|
||||||
|
req.ReportPath = filepath.Join(dir, "seriatim.report.json")
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustRunner(t *testing.T, binary string, report bool) *SubprocessRunner {
|
||||||
|
t.Helper()
|
||||||
|
coalesce := 3.0
|
||||||
|
r, err := NewSubprocessRunner(SubprocessRunnerConfig{
|
||||||
|
Binary: binary,
|
||||||
|
Timeout: mustParseDuration(t, "2s"),
|
||||||
|
OutputSchema: "seriatim-intermediate",
|
||||||
|
CoalesceGap: &coalesce,
|
||||||
|
Report: report,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSubprocessRunner() error = %v", err)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseDuration(t *testing.T, v string) time.Duration {
|
||||||
|
t.Helper()
|
||||||
|
d, err := time.ParseDuration(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("time.ParseDuration(%q) error = %v", v, err)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func flagValue(args []string, name string) string {
|
||||||
|
for i := 0; i < len(args)-1; i++ {
|
||||||
|
if args[i] == name {
|
||||||
|
return args[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSeriatimFile(t *testing.T, path, contents string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(%q): %v", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%q): %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSeriatimHelperFile(path, contents string) {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||||
|
_ = os.WriteFile(path, []byte(contents), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertJSONFile(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%q): %v", path, err)
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal(data, &v); err != nil {
|
||||||
|
t.Fatalf("json unmarshal %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHelperRecord(t *testing.T, path string) helperRecord {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%q): %v", path, err)
|
||||||
|
}
|
||||||
|
var rec helperRecord
|
||||||
|
if err := json.Unmarshal(data, &rec); err != nil {
|
||||||
|
t.Fatalf("json unmarshal helper record: %v", err)
|
||||||
|
}
|
||||||
|
return rec
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user