Files
story/internal/saas/conversation_query.go
T

305 lines
14 KiB
Go
Raw Normal View History

2026-08-26 06:41:07 +08:00
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"`
2026-09-06 17:04:11 +08:00
RuntimeType string `json:"runtime_type"`
2026-08-26 06:41:07 +08:00
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"`
}
2026-09-06 17:04:11 +08:00
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"`
}
2026-08-26 06:41:07 +08:00
type ConversationDetail struct {
ConversationSummary
2026-09-06 17:04:11 +08:00
HouseholdID string `json:"household_id"`
Snapshots ConversationSnapshotSummary `json:"snapshots"`
RTC *RTCTaskSummary `json:"rtc,omitempty"`
HardwareAgent *HardwareAgentTaskSummary `json:"hardware_agent,omitempty"`
History []ConversationStateEntry `json:"history"`
2026-08-26 06:41:07 +08:00
}
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, `
2026-09-06 17:04:11 +08:00
SELECT c.public_id::text,$1::text,d.public_id::text,c.status,c.runtime_type,c.engine_mode,c.retention_mode,
2026-08-26 06:41:07 +08:00
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
2026-09-06 17:04:11 +08:00
var hardwareProvider, hardwareStatus, hardwareTransport *string
var hardwareStartedAt, hardwareEndedAt, hardwareLastEventAt *time.Time
var hardwareLastSequence *int64
2026-08-26 06:41:07 +08:00
err := s.pool.QueryRow(ctx, `
2026-09-06 17:04:11 +08:00
SELECT c.public_id::text,sp.public_id::text,d.public_id::text,c.status,c.runtime_type,c.engine_mode,c.retention_mode,
2026-08-26 06:41:07 +08:00
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,
2026-09-06 17:04:11 +08:00
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
2026-08-26 06:41:07 +08:00
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
2026-09-06 17:04:11 +08:00
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id AND hs.tenant_id=c.tenant_id
2026-08-26 06:41:07 +08:00
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(
2026-09-06 17:04:11 +08:00
&result.ID, &result.SubjectID, &result.DeviceID, &result.Status, &result.RuntimeType, &result.EngineMode, &result.RetentionMode,
2026-08-26 06:41:07 +08:00
&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,
2026-09-06 17:04:11 +08:00
&rtcProvider, &rtcStatus, &rtcLeaseExpiresAt, &rtcStartAttempts, &rtcStopAttempts, &rtcLastCheckedAt,
&hardwareProvider, &hardwareStatus, &hardwareTransport, &hardwareStartedAt, &hardwareEndedAt, &hardwareLastEventAt, &hardwareLastSequence)
2026-08-26 06:41:07 +08:00
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
}
}
2026-09-06 17:04:11 +08:00
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
}
}
2026-08-26 06:41:07 +08:00
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
2026-09-06 17:04:11 +08:00
if err := row.Scan(&item.ID, &item.SubjectID, &item.DeviceID, &item.Status, &item.RuntimeType, &item.EngineMode, &item.RetentionMode,
2026-08-26 06:41:07 +08:00
&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
}