Parse explicit bounded Agent Mock media scenario
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/ai"
|
||||
)
|
||||
|
||||
const maxApprovedMockScenarioBytes = 4 << 20
|
||||
|
||||
type approvedMockScenario struct {
|
||||
InboundPCM16 []byte
|
||||
Script ai.ApprovedMockScript
|
||||
MaxWAVBytes int64
|
||||
ExpectedRecording bool
|
||||
Outcome string
|
||||
ReasonMessage string
|
||||
}
|
||||
|
||||
// loadApprovedMockScenario reads only explicit synthetic media facts. It does
|
||||
// not read or override the AI business configuration delivered by Dispatcher.
|
||||
func loadApprovedMockScenario(path string) (approvedMockScenario, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE is required")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE cannot be opened")
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxApprovedMockScenarioBytes+1))
|
||||
if err != nil || len(data) > maxApprovedMockScenarioBytes {
|
||||
return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE exceeds its size limit or cannot be read")
|
||||
}
|
||||
var encoded struct {
|
||||
InboundPCM16 *string `json:"inbound_pcm16"`
|
||||
Script *ai.ApprovedMockScript `json:"script"`
|
||||
MaxWAVBytes *int64 `json:"max_wav_bytes"`
|
||||
ExpectedRecording *bool `json:"expected_recording"`
|
||||
Outcome *string `json:"outcome"`
|
||||
ReasonMessage *string `json:"reason_message"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&encoded); err != nil {
|
||||
return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE has invalid fields")
|
||||
}
|
||||
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||
return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE contains extra data")
|
||||
}
|
||||
if encoded.InboundPCM16 == nil || encoded.Script == nil || encoded.MaxWAVBytes == nil || encoded.ExpectedRecording == nil || encoded.Outcome == nil || encoded.ReasonMessage == nil {
|
||||
return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE requires explicit media, script, outcome and recording facts")
|
||||
}
|
||||
pcm, err := base64.StdEncoding.DecodeString(*encoded.InboundPCM16)
|
||||
if err != nil || len(encoded.Script.Turns) == 0 || *encoded.MaxWAVBytes <= 44 || strings.TrimSpace(*encoded.ReasonMessage) == "" ||
|
||||
(*encoded.Outcome != "answered" && *encoded.Outcome != "no_answer" && *encoded.Outcome != "failed") {
|
||||
return approvedMockScenario{}, errors.New("AGENT_MOCK_SCENARIO_FILE has invalid synthetic media or outcome")
|
||||
}
|
||||
return approvedMockScenario{
|
||||
InboundPCM16: pcm, Script: *encoded.Script, MaxWAVBytes: *encoded.MaxWAVBytes,
|
||||
ExpectedRecording: *encoded.ExpectedRecording, Outcome: *encoded.Outcome, ReasonMessage: *encoded.ReasonMessage,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/ai"
|
||||
)
|
||||
|
||||
func writeApprovedMockScenario(t *testing.T, fields map[string]any) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "scenario.json")
|
||||
data, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func validApprovedMockScenario() map[string]any {
|
||||
return map[string]any{
|
||||
"inbound_pcm16": bytes.Repeat([]byte{1, 0}, 1600),
|
||||
"script": ai.ApprovedMockScript{Turns: []ai.ApprovedMockTurn{{Transcript: "synthetic final ASR fixture"}}},
|
||||
"max_wav_bytes": int64(4096),
|
||||
"expected_recording": true,
|
||||
"outcome": "answered",
|
||||
"reason_message": "isolated Mock answered",
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadApprovedMockScenarioPreservesExplicitSyntheticMedia(t *testing.T) {
|
||||
path := writeApprovedMockScenario(t, validApprovedMockScenario())
|
||||
scenario, err := loadApprovedMockScenario(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(scenario.InboundPCM16, bytes.Repeat([]byte{1, 0}, 1600)) ||
|
||||
len(scenario.Script.Turns) != 1 || scenario.Script.Turns[0].Transcript != "synthetic final ASR fixture" ||
|
||||
scenario.MaxWAVBytes != 4096 || !scenario.ExpectedRecording || scenario.Outcome != "answered" || scenario.ReasonMessage != "isolated Mock answered" {
|
||||
t.Fatal("explicit isolated Mock facts were changed or silently defaulted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadApprovedMockScenarioRequiresExplicitFieldsAndRejectsUnknown(t *testing.T) {
|
||||
if _, err := loadApprovedMockScenario(""); err == nil {
|
||||
t.Fatal("missing Mock scenario was defaulted")
|
||||
}
|
||||
for _, key := range []string{"inbound_pcm16", "script", "max_wav_bytes", "expected_recording", "outcome", "reason_message"} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
fields := validApprovedMockScenario()
|
||||
delete(fields, key)
|
||||
if _, err := loadApprovedMockScenario(writeApprovedMockScenario(t, fields)); err == nil {
|
||||
t.Fatalf("missing %s was silently defaulted", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
fields := validApprovedMockScenario()
|
||||
fields["legacy_schema_version"] = "v3"
|
||||
if _, err := loadApprovedMockScenario(writeApprovedMockScenario(t, fields)); err == nil || strings.Contains(err.Error(), "synthetic final ASR fixture") {
|
||||
t.Fatalf("unknown implementation version was accepted or scenario content leaked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadApprovedMockScenarioRejectsMalformedAndUnboundedInput(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "scenario.json")
|
||||
for _, data := range [][]byte{
|
||||
[]byte(`{"inbound_pcm16":"%%%","script":{"Turns":[{"Transcript":"synthetic"}]},"max_wav_bytes":4096,"expected_recording":true,"outcome":"answered","reason_message":"isolated"}`),
|
||||
[]byte(`{} {}`),
|
||||
bytes.Repeat([]byte("x"), 4<<20+1),
|
||||
} {
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadApprovedMockScenario(path); err == nil {
|
||||
t.Fatal("malformed or oversized Mock scenario was accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user