Files

133 lines
5.2 KiB
Go
Raw Permalink Normal View History

2026-09-06 17:04:11 +08:00
package orchestration
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/volcsubtitle"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNativeSubtitleSpeaker = errors.New("native subtitle speaker is not mapped")
type NativeSubtitleResult struct {
Mode string
Accepted, Duplicates int
}
type NativeSubtitleService struct{ pool *pgxpool.Pool }
func NewNativeSubtitleService(pool *pgxpool.Pool) *NativeSubtitleService {
return &NativeSubtitleService{pool: pool}
}
// Hardware low_load callbacks currently carry a device identity, but no session
// identity. Validate connectivity without guessing a conversation or storing text.
func (s *NativeSubtitleService) ProbeNativeSubtitleDevice(ctx context.Context, packet volcsubtitle.Packet) error {
var found bool
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM hardware_agent_provider_credential WHERE product_key=$1 AND provider_device_name=$2 AND status='ACTIVE')`, packet.ProductKey, packet.DeviceName).Scan(&found)
if err != nil {
return err
}
if !found {
return ErrConversationNotFound
}
return nil
}
func (s *NativeSubtitleService) ProcessNativeSubtitle(ctx context.Context, publicID string, packet volcsubtitle.Packet) (NativeSubtitleResult, error) {
result := NativeSubtitleResult{Mode: "probe"}
tx, err := s.pool.Begin(ctx)
if err != nil {
return result, err
}
defer tx.Rollback(context.Background())
result, err = s.processNativeSubtitleTx(ctx, tx, publicID, packet)
if err != nil {
return NativeSubtitleResult{}, err
}
return result, tx.Commit(ctx)
}
func (s *NativeSubtitleService) processNativeSubtitleTx(ctx context.Context, tx pgx.Tx, publicID string, packet volcsubtitle.Packet) (NativeSubtitleResult, error) {
result := NativeSubtitleResult{Mode: "probe"}
var id, tenantID, subjectID, retention, status, deviceUID, agentUID, deviceName, botID string
var primary, allowed bool
err := tx.QueryRow(ctx, `SELECT c.id::text,c.tenant_id::text,c.subject_id::text,c.retention_mode,c.status,
COALESCE(hs.capability_snapshot->>'subtitle_device_user_id',''),COALESCE(hs.capability_snapshot->>'subtitle_agent_user_id',''),
COALESCE(hs.capability_snapshot->>'subtitle_device_user_id',''),hs.bot_id,COALESCE((hs.capability_snapshot->>'native_subtitle_primary')::boolean,false),
EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=c.tenant_id AND cr.subject_id=c.subject_id AND cr.consent_type='transcript_storage' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now()))
FROM conversation c JOIN hardware_agent_session hs ON hs.conversation_id=c.id AND hs.tenant_id=c.tenant_id
WHERE c.public_id=$1::uuid FOR UPDATE OF c`, publicID).Scan(&id, &tenantID, &subjectID, &retention, &status, &deviceUID, &agentUID, &deviceName, &botID, &primary, &allowed)
if errors.Is(err, pgx.ErrNoRows) {
return result, ErrConversationNotFound
}
if err != nil {
return result, err
}
if retention == "none" || !allowed {
return result, ErrTranscriptStorageNotAllowed
}
if status != "ROOM_ISSUED" && status != "ACTIVE" && status != "STOPPING" && status != "COMPLETED" && status != "FAILED" {
return result, ErrConversationStateConflict
}
if !primary {
return result, nil
}
result.Mode = "primary"
for _, part := range packet.Data {
// Server callbacks contain independent completed clauses; retaining only
// paragraph=true would silently drop all preceding clauses of the reply.
if !part.Definite || strings.TrimSpace(part.Text) == "" {
continue
}
speaker, err := nativeSubtitleSpeaker(part.UserID, deviceUID, deviceName, agentUID, botID)
if err != nil {
return result, err
}
digest := sha256.Sum256([]byte(part.UserID))
key := fmt.Sprintf("volc-subv-%s-%d-%d", hex.EncodeToString(digest[:8]), *part.RoundID, *part.Sequence)
var stored string
err = tx.QueryRow(ctx, `SELECT final_text FROM conversation_turn WHERE conversation_id=$1::uuid AND turn_key=$2`, id, key).Scan(&stored)
if err == nil {
if stored != part.Text {
return result, ErrHardwareEventConflict
}
result.Duplicates++
continue
}
if !errors.Is(err, pgx.ErrNoRows) {
return result, err
}
_, err = 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)
SELECT $1::uuid,$2::uuid,$3::uuid,$4,COALESCE(max(sequence_no),0)+1,$5,now(),now(),$6,false,'provider' FROM conversation_turn WHERE conversation_id=$3::uuid`, tenantID, subjectID, id, key, speaker, part.Text)
if err != nil {
return result, err
}
result.Accepted++
}
if result.Accepted > 0 && (status == "COMPLETED" || status == "FAILED") {
if err = enqueueHardwareContentExtraction(ctx, tx, outbox.NewStore(s.pool), tenantID, id); err != nil {
return result, err
}
}
return result, nil
}
func nativeSubtitleSpeaker(uid, deviceUID, deviceName, agentUID, botID string) (string, error) {
subject := uid != "" && (uid == deviceUID || uid == deviceName)
agent := uid != "" && (uid == agentUID || uid == botID)
if subject == agent {
return "", ErrNativeSubtitleSpeaker
}
if subject {
return "subject", nil
}
return "agent", nil
}