2026-08-26 06:41:07 +08:00
package orchestration
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/outbox"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrTranscriptStorageNotAllowed = errors . New ( "transcript storage is not allowed" )
type TranscriptTurnInput struct {
TurnKey string `json:"turn_key"`
SequenceNo int64 `json:"sequence_no"`
Speaker string `json:"speaker"`
StartAt time . Time `json:"start_at"`
EndAt time . Time `json:"end_at"`
FinalText string `json:"final_text"`
Interrupted bool `json:"interrupted"`
}
type TranscriptBatchInput struct {
Turns [] TranscriptTurnInput `json:"turns"`
}
type TranscriptConflict struct {
TurnKey string `json:"turn_key"`
SequenceNo int64 `json:"sequence_no"`
Reason string `json:"reason"`
}
type TranscriptConflictError struct {
Conflicts [] TranscriptConflict
}
func ( e * TranscriptConflictError ) Error () string {
return "device transcript conflicts with stored final turns"
}
type TranscriptBatchResponse struct {
ConversationID string `json:"conversation_id"`
AcceptedCount int `json:"accepted_count"`
DuplicateCount int `json:"duplicate_count"`
}
type TranscriptBatchService struct {
pool * pgxpool . Pool
executor * idempotency . Executor
audit * audit . Repository
outbox * outbox . Store
}
func NewTranscriptBatchService ( pool * pgxpool . Pool , executor * idempotency . Executor , auditRepository * audit . Repository , outboxStore * outbox . Store ) ( * TranscriptBatchService , error ) {
if pool == nil || executor == nil || auditRepository == nil || outboxStore == nil {
return nil , errors . New ( "transcript batch dependencies are required" )
}
return & TranscriptBatchService { pool : pool , executor : executor , audit : auditRepository , outbox : outboxStore }, nil
}
func ( s * TranscriptBatchService ) Submit ( ctx context . Context , identity deviceauth . Identity , conversationID , idempotencyKey , requestID string , rawRequest [] byte , input TranscriptBatchInput ) ( TranscriptBatchResponse , bool , error ) {
tenantID := identity . TenantID
scope := "POST /device/v2/conversations/" + conversationID + "/turns:batch"
result , err := s . executor . Execute ( ctx , idempotency . Request {
TenantID : & tenantID , ActorKey : "device:" + identity . PublicID , Scope : scope ,
Key : idempotencyKey , RequestHash : idempotency . Hash ( rawRequest ), ExpiresAfter : 24 * time . Hour ,
}, func ( operationContext context . Context , tx pgx . Tx ) ( idempotency . Result , error ) {
response , internalID , submitErr := s . submit ( operationContext , tx , identity , conversationID , input )
if submitErr != nil {
return idempotency . Result {}, submitErr
}
body , marshalErr := json . Marshal ( response )
if marshalErr != nil {
return idempotency . Result {}, marshalErr
}
actorID := identity . PublicID
if auditErr := s . audit . Record ( operationContext , tx , audit . Event {
TenantID : & tenantID , ActorType : "device" , ActorPublicID : & actorID ,
Action : "conversation.transcript_batch.submit" , ResourceType : "conversation" , ResourceID : & internalID ,
RequestID : requestID , Result : "success" , Detail : map [ string ] any {
"accepted_count" : response . AcceptedCount , "duplicate_count" : response . DuplicateCount ,
},
}); auditErr != nil {
return idempotency . Result {}, auditErr
}
if response . AcceptedCount > 0 {
var generationVersion int64
if versionErr := tx . QueryRow ( operationContext , `SELECT COALESCE(max(sequence_no),1) FROM conversation_turn WHERE conversation_id=$1::uuid` , internalID ). Scan ( & generationVersion ); versionErr != nil {
return idempotency . Result {}, versionErr
}
deduplicationKey := "content-extraction-v1:" + internalID + ":device:" + idempotency . Hash ( rawRequest )
payload , marshalErr := json . Marshal ( map [ string ] int64 { "generation_version" : generationVersion })
if marshalErr != nil {
return idempotency . Result {}, marshalErr
}
if _ , enqueueErr := s . outbox . Enqueue ( operationContext , tx , outbox . NewEvent {
TenantID : & tenantID , AggregateType : "conversation" , AggregateID : internalID ,
EventType : "conversation.content_extraction_requested" , DeduplicationKey : & deduplicationKey ,
Payload : payload ,
}); enqueueErr != nil {
return idempotency . Result {}, enqueueErr
}
}
return idempotency . Result { StatusCode : http . StatusOK , Body : body , ResourceID : & internalID }, nil
})
if err != nil {
return TranscriptBatchResponse {}, false , err
}
var response TranscriptBatchResponse
if err := json . Unmarshal ( result . Body , & response ); err != nil {
return TranscriptBatchResponse {}, result . Replayed , fmt . Errorf ( "decode transcript batch response: %w" , err )
}
return response , result . Replayed , nil
}
func ( s * TranscriptBatchService ) submit ( ctx context . Context , tx pgx . Tx , identity deviceauth . Identity , conversationID string , input TranscriptBatchInput ) ( TranscriptBatchResponse , string , error ) {
var internalID , subjectID , retentionMode , status string
err := tx . QueryRow ( ctx , `
SELECT c.id::text,c.subject_id::text,c.retention_mode,c.status
FROM conversation c
WHERE c.public_id=$1::uuid AND c.tenant_id=$2::uuid AND c.device_id=$3::uuid
FOR UPDATE` , conversationID , identity . TenantID , identity . ID ). Scan ( & internalID , & subjectID , & retentionMode , & status )
if errors . Is ( err , pgx . ErrNoRows ) {
return TranscriptBatchResponse {}, "" , ErrConversationNotFound
}
if err != nil {
return TranscriptBatchResponse {}, "" , fmt . Errorf ( "read transcript conversation: %w" , err )
}
if status != "ACTIVE" && status != "STOPPING" && status != "COMPLETED" && status != "FAILED" {
return TranscriptBatchResponse {}, "" , ErrConversationStateConflict
}
if retentionMode == "none" {
return TranscriptBatchResponse {}, "" , ErrTranscriptStorageNotAllowed
}
2026-09-06 17:04:11 +08:00
var nativePrimary bool
if err := tx . QueryRow ( ctx , `SELECT EXISTS(SELECT 1 FROM hardware_agent_session WHERE conversation_id=$1::uuid AND tenant_id=$2::uuid AND capability_snapshot->>'native_subtitle_primary'='true')` , internalID , identity . TenantID ). Scan ( & nativePrimary ); err != nil {
return TranscriptBatchResponse {}, "" , err
}
if nativePrimary {
return TranscriptBatchResponse {}, "" , ErrConversationStateConflict
}
2026-08-26 06:41:07 +08:00
var hasConsentHistory , hasActiveConsent bool
if err := tx . QueryRow ( ctx , `
SELECT
EXISTS(SELECT 1 FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type='transcript_storage'),
EXISTS(SELECT 1 FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type='transcript_storage' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now()))` ,
identity . TenantID , subjectID ). Scan ( & hasConsentHistory , & hasActiveConsent ); err != nil {
return TranscriptBatchResponse {}, "" , err
}
if hasConsentHistory && ! hasActiveConsent {
return TranscriptBatchResponse {}, "" , ErrTranscriptStorageNotAllowed
}
response := TranscriptBatchResponse { ConversationID : conversationID }
conflicts := make ([] TranscriptConflict , 0 )
for _ , turn := range input . Turns {
rows , queryErr := tx . Query ( ctx , `
SELECT turn_key,sequence_no,speaker,start_at,end_at,final_text,interrupted
FROM conversation_turn
WHERE conversation_id=$1::uuid AND (turn_key=$2 OR sequence_no=$3)
FOR UPDATE` , internalID , turn . TurnKey , turn . SequenceNo )
if queryErr != nil {
return TranscriptBatchResponse {}, "" , queryErr
}
matches := 0
exact := false
for rows . Next () {
matches ++
var stored TranscriptTurnInput
if scanErr := rows . Scan ( & stored . TurnKey , & stored . SequenceNo , & stored . Speaker , & stored . StartAt , & stored . EndAt , & stored . FinalText , & stored . Interrupted ); scanErr != nil {
rows . Close ()
return TranscriptBatchResponse {}, "" , scanErr
}
if sameTranscriptTurn ( stored , turn ) {
exact = true
}
}
rows . Close ()
if rows . Err () != nil {
return TranscriptBatchResponse {}, "" , rows . Err ()
}
if matches > 0 {
if matches == 1 && exact {
response . DuplicateCount ++
continue
}
conflicts = append ( conflicts , TranscriptConflict { TurnKey : turn . TurnKey , SequenceNo : turn . SequenceNo , Reason : "TURN_KEY_OR_SEQUENCE_ALREADY_USED" })
continue
}
if _ , insertErr := tx . Exec ( ctx , `
INSERT INTO conversation_turn(
tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,interrupted,source_type
) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,$9,$10,'device')` ,
identity . TenantID , subjectID , internalID , turn . TurnKey , turn . SequenceNo , turn . Speaker ,
turn . StartAt , turn . EndAt , turn . FinalText , turn . Interrupted ); insertErr != nil {
return TranscriptBatchResponse {}, "" , insertErr
}
response . AcceptedCount ++
}
if len ( conflicts ) > 0 {
return TranscriptBatchResponse {}, "" , & TranscriptConflictError { Conflicts : conflicts }
}
return response , internalID , nil
}
func sameTranscriptTurn ( left , right TranscriptTurnInput ) bool {
return left . TurnKey == right . TurnKey && left . SequenceNo == right . SequenceNo && left . Speaker == right . Speaker &&
left . StartAt . Equal ( right . StartAt ) && left . EndAt . Equal ( right . EndAt ) && left . FinalText == right . FinalText &&
left . Interrupted == right . Interrupted
}