101 lines
4.4 KiB
Go
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{},
|
|
})
|
|
}
|