305 lines
14 KiB
Go
305 lines
14 KiB
Go
package saas
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"data-terminal-cloud/internal/billing"
|
|
"data-terminal-cloud/internal/identity"
|
|
"data-terminal-cloud/internal/pagination"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type ConversationFilter struct {
|
|
Status string
|
|
EngineMode string
|
|
From *time.Time
|
|
To *time.Time
|
|
Limit int
|
|
Cursor string
|
|
}
|
|
|
|
type ConversationSummary struct {
|
|
ID string `json:"id"`
|
|
SubjectID string `json:"subject_id"`
|
|
DeviceID string `json:"device_id"`
|
|
Status string `json:"status"`
|
|
RuntimeType string `json:"runtime_type"`
|
|
EngineMode string `json:"engine_mode"`
|
|
RetentionMode string `json:"retention_mode"`
|
|
AudioRetentionEnabled bool `json:"audio_retention_enabled"`
|
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
|
EndedAt *time.Time `json:"ended_at,omitempty"`
|
|
DurationMS *int64 `json:"duration_ms,omitempty"`
|
|
StopReason *string `json:"stop_reason,omitempty"`
|
|
FailureCode *string `json:"failure_code,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type ConversationPage struct {
|
|
Items []ConversationSummary `json:"items"`
|
|
NextCursor string `json:"next_cursor,omitempty"`
|
|
}
|
|
|
|
type ConversationStateEntry struct {
|
|
FromStatus *string `json:"from_status,omitempty"`
|
|
ToStatus string `json:"to_status"`
|
|
Reason *string `json:"reason,omitempty"`
|
|
Source string `json:"source"`
|
|
OccurredAt time.Time `json:"occurred_at"`
|
|
}
|
|
|
|
type ConversationSnapshotSummary struct {
|
|
ConsentSnapshotID string `json:"consent_snapshot_id"`
|
|
ConsentSnapshotVersion int64 `json:"consent_snapshot_version"`
|
|
ContextSnapshotID string `json:"context_snapshot_id"`
|
|
ProfileVersion int64 `json:"profile_version"`
|
|
PromptBundleVersion string `json:"prompt_bundle_version"`
|
|
ContextPreparationStatus string `json:"context_preparation_status"`
|
|
ContextPreparationFailureCode *string `json:"context_preparation_failure_code,omitempty"`
|
|
}
|
|
|
|
type RTCTaskSummary struct {
|
|
Provider string `json:"provider"`
|
|
Status string `json:"status"`
|
|
LeaseExpiresAt *time.Time `json:"lease_expires_at,omitempty"`
|
|
StartAttempts int `json:"start_attempts"`
|
|
StopAttempts int `json:"stop_attempts"`
|
|
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
|
|
}
|
|
|
|
type HardwareAgentTaskSummary struct {
|
|
Provider string `json:"provider"`
|
|
Status string `json:"status"`
|
|
Transport string `json:"transport"`
|
|
ProviderStartedAt *time.Time `json:"provider_started_at,omitempty"`
|
|
ProviderEndedAt *time.Time `json:"provider_ended_at,omitempty"`
|
|
LastEventAt *time.Time `json:"last_event_at,omitempty"`
|
|
LastEventSequence int64 `json:"last_event_sequence"`
|
|
}
|
|
|
|
type ConversationDetail struct {
|
|
ConversationSummary
|
|
HouseholdID string `json:"household_id"`
|
|
Snapshots ConversationSnapshotSummary `json:"snapshots"`
|
|
RTC *RTCTaskSummary `json:"rtc,omitempty"`
|
|
HardwareAgent *HardwareAgentTaskSummary `json:"hardware_agent,omitempty"`
|
|
History []ConversationStateEntry `json:"history"`
|
|
}
|
|
|
|
type ConversationUsageSummary struct {
|
|
ConversationID string `json:"conversation_id"`
|
|
CostMicros int64 `json:"cost_micros"`
|
|
Currency string `json:"currency"`
|
|
Lines []billing.CostSummaryLine `json:"lines"`
|
|
}
|
|
|
|
func (s *Service) ListSubjectConversations(ctx context.Context, principal identity.Principal, subjectPublicID string, filter ConversationFilter) (ConversationPage, error) {
|
|
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilityConversationRead, false)
|
|
if err != nil {
|
|
return ConversationPage{}, err
|
|
}
|
|
if s.cursor == nil {
|
|
return ConversationPage{}, errors.New("pagination cursor codec is unavailable")
|
|
}
|
|
var cursorTime *time.Time
|
|
var cursorID *string
|
|
if filter.Cursor != "" {
|
|
decoded, decodeErr := s.cursor.Decode(filter.Cursor)
|
|
if decodeErr != nil {
|
|
return ConversationPage{}, pagination.ErrInvalidCursor
|
|
}
|
|
parsed, parseErr := time.Parse(time.RFC3339Nano, decoded.SortKey)
|
|
if parseErr != nil {
|
|
return ConversationPage{}, pagination.ErrInvalidCursor
|
|
}
|
|
cursorTime = &parsed
|
|
cursorID = &decoded.ID
|
|
}
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT c.public_id::text,$1::text,d.public_id::text,c.status,c.runtime_type,c.engine_mode,c.retention_mode,
|
|
c.started_at,c.ended_at,CASE WHEN c.started_at IS NOT NULL AND c.ended_at IS NOT NULL THEN (EXTRACT(EPOCH FROM (c.ended_at-c.started_at))*1000)::bigint END,
|
|
c.stop_reason,c.failure_code,c.created_at,c.updated_at
|
|
FROM conversation c JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id
|
|
WHERE c.tenant_id=$2::uuid AND c.subject_id=$3::uuid
|
|
AND (NULLIF($4,'') IS NULL OR c.status=NULLIF($4,''))
|
|
AND (NULLIF($5,'') IS NULL OR c.engine_mode=NULLIF($5,''))
|
|
AND ($6::timestamptz IS NULL OR c.created_at >= $6)
|
|
AND ($7::timestamptz IS NULL OR c.created_at < $7)
|
|
AND ($8::timestamptz IS NULL OR (c.created_at,c.public_id)<($8,$9::uuid))
|
|
ORDER BY c.created_at DESC,c.public_id DESC LIMIT $10`, subjectPublicID, access.tenantID, access.subjectID,
|
|
filter.Status, filter.EngineMode, filter.From, filter.To, cursorTime, cursorID, filter.Limit+1)
|
|
if err != nil {
|
|
return ConversationPage{}, fmt.Errorf("list subject conversations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
items := make([]ConversationSummary, 0, filter.Limit+1)
|
|
for rows.Next() {
|
|
item, scanErr := scanConversationSummary(rows)
|
|
if scanErr != nil {
|
|
return ConversationPage{}, scanErr
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return ConversationPage{}, fmt.Errorf("iterate subject conversations: %w", err)
|
|
}
|
|
page := ConversationPage{Items: items}
|
|
if len(items) > filter.Limit {
|
|
page.Items = items[:filter.Limit]
|
|
last := page.Items[len(page.Items)-1]
|
|
page.NextCursor, err = s.cursor.Encode(last.CreatedAt.UTC().Format(time.RFC3339Nano), last.ID)
|
|
if err != nil {
|
|
return ConversationPage{}, fmt.Errorf("encode next conversation cursor: %w", err)
|
|
}
|
|
}
|
|
return page, nil
|
|
}
|
|
|
|
func (s *Service) GetConversation(ctx context.Context, principal identity.Principal, conversationPublicID string) (ConversationDetail, error) {
|
|
var result ConversationDetail
|
|
var duration *int64
|
|
var rtcProvider, rtcStatus *string
|
|
var rtcLeaseExpiresAt, rtcLastCheckedAt *time.Time
|
|
var rtcStartAttempts, rtcStopAttempts *int
|
|
var hardwareProvider, hardwareStatus, hardwareTransport *string
|
|
var hardwareStartedAt, hardwareEndedAt, hardwareLastEventAt *time.Time
|
|
var hardwareLastSequence *int64
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT c.public_id::text,sp.public_id::text,d.public_id::text,c.status,c.runtime_type,c.engine_mode,c.retention_mode,
|
|
c.started_at,c.ended_at,CASE WHEN c.started_at IS NOT NULL AND c.ended_at IS NOT NULL THEN (EXTRACT(EPOCH FROM (c.ended_at-c.started_at))*1000)::bigint END,
|
|
c.stop_reason,c.failure_code,c.created_at,c.updated_at,h.public_id::text,
|
|
cs.public_id::text,cs.snapshot_version,xs.public_id::text,xs.profile_version,xs.prompt_bundle_version,
|
|
xs.preparation_status,xs.preparation_failure_code,
|
|
r.provider,r.status,r.lease_expires_at,r.start_attempts,r.stop_attempts,r.last_checked_at,
|
|
hs.provider,hs.status,hs.transport,hs.provider_started_at,hs.provider_ended_at,hs.last_event_at,hs.last_event_sequence
|
|
FROM conversation c
|
|
JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id AND h.status <> 'deleted'
|
|
JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id AND sp.status IN ('active','suspended')
|
|
JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id
|
|
JOIN consent_snapshot cs ON cs.id=c.consent_snapshot_id AND cs.tenant_id=c.tenant_id
|
|
JOIN context_snapshot xs ON xs.id=c.context_snapshot_id AND xs.tenant_id=c.tenant_id
|
|
LEFT JOIN rtc_task r ON r.conversation_id=c.id
|
|
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id AND hs.tenant_id=c.tenant_id
|
|
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
|
|
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
|
|
WHERE c.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, conversationPublicID, principal.Issuer, principal.Subject).Scan(
|
|
&result.ID, &result.SubjectID, &result.DeviceID, &result.Status, &result.RuntimeType, &result.EngineMode, &result.RetentionMode,
|
|
&result.StartedAt, &result.EndedAt, &duration, &result.StopReason, &result.FailureCode, &result.CreatedAt, &result.UpdatedAt, &result.HouseholdID,
|
|
&result.Snapshots.ConsentSnapshotID, &result.Snapshots.ConsentSnapshotVersion, &result.Snapshots.ContextSnapshotID, &result.Snapshots.ProfileVersion, &result.Snapshots.PromptBundleVersion,
|
|
&result.Snapshots.ContextPreparationStatus, &result.Snapshots.ContextPreparationFailureCode,
|
|
&rtcProvider, &rtcStatus, &rtcLeaseExpiresAt, &rtcStartAttempts, &rtcStopAttempts, &rtcLastCheckedAt,
|
|
&hardwareProvider, &hardwareStatus, &hardwareTransport, &hardwareStartedAt, &hardwareEndedAt, &hardwareLastEventAt, &hardwareLastSequence)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ConversationDetail{}, ErrAccessDenied
|
|
}
|
|
if err != nil {
|
|
return ConversationDetail{}, fmt.Errorf("get conversation detail: %w", err)
|
|
}
|
|
result.DurationMS = duration
|
|
result.AudioRetentionEnabled = result.RetentionMode == "transcript_and_audio"
|
|
if rtcProvider != nil {
|
|
result.RTC = &RTCTaskSummary{Provider: *rtcProvider, LeaseExpiresAt: rtcLeaseExpiresAt, LastCheckedAt: rtcLastCheckedAt}
|
|
if rtcStatus != nil {
|
|
result.RTC.Status = *rtcStatus
|
|
}
|
|
if rtcStartAttempts != nil {
|
|
result.RTC.StartAttempts = *rtcStartAttempts
|
|
}
|
|
if rtcStopAttempts != nil {
|
|
result.RTC.StopAttempts = *rtcStopAttempts
|
|
}
|
|
}
|
|
if hardwareProvider != nil {
|
|
result.HardwareAgent = &HardwareAgentTaskSummary{Provider: *hardwareProvider, ProviderStartedAt: hardwareStartedAt,
|
|
ProviderEndedAt: hardwareEndedAt, LastEventAt: hardwareLastEventAt}
|
|
if hardwareStatus != nil {
|
|
result.HardwareAgent.Status = *hardwareStatus
|
|
}
|
|
if hardwareTransport != nil {
|
|
result.HardwareAgent.Transport = *hardwareTransport
|
|
}
|
|
if hardwareLastSequence != nil {
|
|
result.HardwareAgent.LastEventSequence = *hardwareLastSequence
|
|
}
|
|
}
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT h.from_status,h.to_status,h.reason,h.source,h.occurred_at
|
|
FROM conversation_state_history h JOIN conversation c ON c.id=h.conversation_id
|
|
WHERE c.public_id=$1::uuid ORDER BY h.occurred_at,h.id`, conversationPublicID)
|
|
if err != nil {
|
|
return ConversationDetail{}, fmt.Errorf("list conversation history: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
result.History = make([]ConversationStateEntry, 0)
|
|
for rows.Next() {
|
|
var item ConversationStateEntry
|
|
if err := rows.Scan(&item.FromStatus, &item.ToStatus, &item.Reason, &item.Source, &item.OccurredAt); err != nil {
|
|
return ConversationDetail{}, fmt.Errorf("scan conversation history: %w", err)
|
|
}
|
|
result.History = append(result.History, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return ConversationDetail{}, fmt.Errorf("iterate conversation history: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) GetConversationUsage(ctx context.Context, principal identity.Principal, conversationPublicID string) (ConversationUsageSummary, error) {
|
|
var conversationID string
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT c.id::text
|
|
FROM conversation c
|
|
JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id AND h.status<>'deleted'
|
|
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
|
|
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
|
|
WHERE c.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, conversationPublicID, principal.Issuer, principal.Subject).Scan(&conversationID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ConversationUsageSummary{}, ErrAccessDenied
|
|
}
|
|
if err != nil {
|
|
return ConversationUsageSummary{}, fmt.Errorf("authorize conversation usage: %w", err)
|
|
}
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT cue.usage_category,cue.metric_code,SUM(cue.quantity)::text,cue.unit,SUM(ccl.cost_micros)::bigint,ccl.currency
|
|
FROM conversation_usage_event cue JOIN conversation_cost_line ccl ON ccl.usage_event_id=cue.id
|
|
WHERE cue.conversation_id=$1::uuid
|
|
GROUP BY cue.usage_category,cue.metric_code,cue.unit,ccl.currency
|
|
ORDER BY cue.usage_category,cue.metric_code,cue.unit,ccl.currency`, conversationID)
|
|
if err != nil {
|
|
return ConversationUsageSummary{}, fmt.Errorf("list conversation usage: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
result := ConversationUsageSummary{ConversationID: conversationPublicID, Lines: []billing.CostSummaryLine{}}
|
|
for rows.Next() {
|
|
var item billing.CostSummaryLine
|
|
if err = rows.Scan(&item.UsageCategory, &item.MetricCode, &item.Quantity, &item.Unit, &item.CostMicros, &item.Currency); err != nil {
|
|
return ConversationUsageSummary{}, fmt.Errorf("scan conversation usage: %w", err)
|
|
}
|
|
if result.Currency != "" && result.Currency != item.Currency {
|
|
return ConversationUsageSummary{}, fmt.Errorf("conversation usage contains multiple currencies")
|
|
}
|
|
result.Currency = item.Currency
|
|
result.CostMicros += item.CostMicros
|
|
result.Lines = append(result.Lines, item)
|
|
}
|
|
if err = rows.Err(); err != nil {
|
|
return ConversationUsageSummary{}, fmt.Errorf("iterate conversation usage: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func scanConversationSummary(row rowScanner) (ConversationSummary, error) {
|
|
var item ConversationSummary
|
|
if err := row.Scan(&item.ID, &item.SubjectID, &item.DeviceID, &item.Status, &item.RuntimeType, &item.EngineMode, &item.RetentionMode,
|
|
&item.StartedAt, &item.EndedAt, &item.DurationMS, &item.StopReason, &item.FailureCode, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
|
return ConversationSummary{}, err
|
|
}
|
|
item.AudioRetentionEnabled = item.RetentionMode == "transcript_and_audio"
|
|
return item, nil
|
|
}
|