Files
go-sip/internal/callflow/result_payload.go
T

101 lines
4.4 KiB
Go

package callflow
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// FinalCallFacts are observed or approved call facts, never defaults inferred
// from the Agent SDK. The reason code is nil if no actual SIP code is known.
type FinalCallFacts struct {
TaskID string
CallerProfileID string
Callee string
TrunkID string
StartedAt time.Time
EndedAt time.Time
Outcome string
ReasonCode *int
ReasonMessage string
}
// FinalResultPayload creates the one current MQ result body. Only final
// user-side ASR text with observed capture timing is included; unplayed LLM
// replies, interim ASR and hypothetical recordings are never transcribed.
func FinalResultPayload(facts FinalCallFacts, call Result) ([]byte, error) {
if strings.TrimSpace(facts.TaskID) == "" || strings.TrimSpace(facts.CallerProfileID) == "" || strings.TrimSpace(facts.Callee) == "" || strings.TrimSpace(facts.TrunkID) == "" || strings.TrimSpace(facts.ReasonMessage) == "" {
return nil, errors.New("final call result is missing approved identity or observed reason")
}
if facts.StartedAt.IsZero() || facts.EndedAt.IsZero() || facts.EndedAt.Before(facts.StartedAt) {
return nil, errors.New("final call result has no valid observed time range")
}
if facts.Outcome != "answered" && facts.Outcome != "no_answer" && facts.Outcome != "failed" {
return nil, errors.New("final call result has no approved outcome")
}
if facts.Outcome == "no_answer" && len(call.Turns) != 0 {
return nil, errors.New("no-answer call cannot contain completed user turns")
}
if facts.ReasonCode != nil && (*facts.ReasonCode < 100 || *facts.ReasonCode > 699) {
return nil, errors.New("final call result has an invalid SIP response code")
}
if len(call.CaptureWindows) < len(call.Turns) {
return nil, errors.New("final ASR text has no corresponding observed media window")
}
type transcriptSegment struct {
SegmentID string `json:"segment_id"`
TurnID string `json:"turn_id"`
Role string `json:"role"`
StartMS int64 `json:"start_ms"`
EndMS int64 `json:"end_ms"`
Text string `json:"text"`
}
segments := make([]transcriptSegment, 0, len(call.Turns))
optOut := false
callDuration := facts.EndedAt.Sub(facts.StartedAt).Milliseconds()
for i, turn := range call.Turns {
window := call.CaptureWindows[i]
if window.StartedAt.Before(facts.StartedAt) || window.EndedAt.Before(window.StartedAt) || window.EndedAt.After(facts.EndedAt) {
return nil, fmt.Errorf("turn %d has no valid observed capture times", i+1)
}
start, end := window.StartedAt.Sub(facts.StartedAt).Milliseconds(), window.EndedAt.Sub(facts.StartedAt).Milliseconds()
if start < 0 || end < start || end > callDuration {
return nil, fmt.Errorf("turn %d capture is outside confirmed call duration", i+1)
}
if turn.EndedByKeyword {
if strings.TrimSpace(turn.Transcript) == "" {
return nil, fmt.Errorf("turn %d claims opt-out without final user ASR text", i+1)
}
optOut = true
}
if turn.Transcript != "" {
segments = append(segments, transcriptSegment{
SegmentID: fmt.Sprintf("segment-%d", len(segments)+1), TurnID: fmt.Sprintf("turn-%d", i+1),
Role: "user", StartMS: start, EndMS: end, Text: turn.Transcript,
})
}
}
return json.Marshal(struct {
TaskID string `json:"task_id"`
CallerProfileID string `json:"caller_profile_id"`
Callee string `json:"callee"`
TrunkID string `json:"trunk_id"`
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at"`
DurationMS int64 `json:"duration_ms"`
Outcome string `json:"outcome"`
ReasonCode *int `json:"reason_code"`
ReasonMessage string `json:"reason_message"`
Transcript []transcriptSegment `json:"transcript"`
OptOut bool `json:"opt_out"`
Recording map[string]any `json:"recording"`
}{
TaskID: facts.TaskID, CallerProfileID: facts.CallerProfileID, Callee: facts.Callee, TrunkID: facts.TrunkID,
StartedAt: facts.StartedAt.UTC().Format(time.RFC3339Nano), EndedAt: facts.EndedAt.UTC().Format(time.RFC3339Nano),
DurationMS: callDuration, Outcome: facts.Outcome, ReasonCode: facts.ReasonCode,
ReasonMessage: facts.ReasonMessage, Transcript: segments, OptOut: optOut, Recording: map[string]any{},
})
}