Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 37s
CI/CD / test-python-backend-compliance (push) Successful in 39s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Interactive Training Videos (CP-TRAIN): - DB migration 022: training_checkpoints + checkpoint_progress tables - NarratorScript generation via Anthropic (AI Teacher persona, German) - TTS batch synthesis + interactive video pipeline (slides + checkpoint slides + FFmpeg) - 4 new API endpoints: generate-interactive, interactive-manifest, checkpoint submit, checkpoint progress - InteractiveVideoPlayer component (HTML5 Video, quiz overlay, seek protection, progress tracking) - Learner portal integration with automatic completion on all checkpoints passed - 30 new tests (handler validation + grading logic + manifest/progress + seek protection) Training Blocks: - Block generator, block store, block config CRUD + preview/generate endpoints - Migration 021: training_blocks schema Control Generator + Canonical Library: - Control generator routes + service enhancements - Canonical control library helpers, sidebar entry - Citation backfill service + tests - CE libraries data (hazard, protection, evidence, lifecycle, components) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
351 lines
11 KiB
Go
351 lines
11 KiB
Go
package training
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// MediaType represents audio or video
|
|
type MediaType string
|
|
|
|
const (
|
|
MediaTypeAudio MediaType = "audio"
|
|
MediaTypeVideo MediaType = "video"
|
|
MediaTypeInteractiveVideo MediaType = "interactive_video"
|
|
)
|
|
|
|
// MediaStatus represents the processing status
|
|
type MediaStatus string
|
|
|
|
const (
|
|
MediaStatusProcessing MediaStatus = "processing"
|
|
MediaStatusCompleted MediaStatus = "completed"
|
|
MediaStatusFailed MediaStatus = "failed"
|
|
)
|
|
|
|
// TrainingMedia represents a generated media file
|
|
type TrainingMedia struct {
|
|
ID uuid.UUID `json:"id"`
|
|
ModuleID uuid.UUID `json:"module_id"`
|
|
ContentID *uuid.UUID `json:"content_id,omitempty"`
|
|
MediaType MediaType `json:"media_type"`
|
|
Status MediaStatus `json:"status"`
|
|
Bucket string `json:"bucket"`
|
|
ObjectKey string `json:"object_key"`
|
|
FileSizeBytes int64 `json:"file_size_bytes"`
|
|
DurationSeconds float64 `json:"duration_seconds"`
|
|
MimeType string `json:"mime_type"`
|
|
VoiceModel string `json:"voice_model"`
|
|
Language string `json:"language"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
GeneratedBy string `json:"generated_by"`
|
|
IsPublished bool `json:"is_published"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// ============================================================================
|
|
// TTS Client
|
|
// ============================================================================
|
|
|
|
// TTSClient communicates with the compliance-tts-service
|
|
type TTSClient struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewTTSClient creates a new TTS service client
|
|
func NewTTSClient(baseURL string) *TTSClient {
|
|
return &TTSClient{
|
|
baseURL: baseURL,
|
|
httpClient: &http.Client{
|
|
Timeout: 5 * time.Minute,
|
|
},
|
|
}
|
|
}
|
|
|
|
// TTSSynthesizeRequest is the request to synthesize audio
|
|
type TTSSynthesizeRequest struct {
|
|
Text string `json:"text"`
|
|
Language string `json:"language"`
|
|
Voice string `json:"voice"`
|
|
ModuleID string `json:"module_id"`
|
|
ContentID string `json:"content_id,omitempty"`
|
|
}
|
|
|
|
// TTSSynthesizeResponse is the response from audio synthesis
|
|
type TTSSynthesizeResponse struct {
|
|
AudioID string `json:"audio_id"`
|
|
Bucket string `json:"bucket"`
|
|
ObjectKey string `json:"object_key"`
|
|
DurationSeconds float64 `json:"duration_seconds"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
}
|
|
|
|
// TTSGenerateVideoRequest is the request to generate a video
|
|
type TTSGenerateVideoRequest struct {
|
|
Script map[string]interface{} `json:"script"`
|
|
AudioObjectKey string `json:"audio_object_key"`
|
|
ModuleID string `json:"module_id"`
|
|
}
|
|
|
|
// TTSGenerateVideoResponse is the response from video generation
|
|
type TTSGenerateVideoResponse struct {
|
|
VideoID string `json:"video_id"`
|
|
Bucket string `json:"bucket"`
|
|
ObjectKey string `json:"object_key"`
|
|
DurationSeconds float64 `json:"duration_seconds"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
}
|
|
|
|
// Synthesize calls the TTS service to create audio
|
|
func (c *TTSClient) Synthesize(ctx context.Context, req *TTSSynthesizeRequest) (*TTSSynthesizeResponse, error) {
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/synthesize", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("TTS service request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("TTS service error (%d): %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result TTSSynthesizeResponse
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return nil, fmt.Errorf("parse TTS response: %w", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// GenerateVideo calls the TTS service to create a presentation video
|
|
func (c *TTSClient) GenerateVideo(ctx context.Context, req *TTSGenerateVideoRequest) (*TTSGenerateVideoResponse, error) {
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/generate-video", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("TTS service request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("TTS service error (%d): %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result TTSGenerateVideoResponse
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return nil, fmt.Errorf("parse TTS response: %w", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// PresignedURLRequest is the request to get a presigned URL
|
|
type PresignedURLRequest struct {
|
|
Bucket string `json:"bucket"`
|
|
ObjectKey string `json:"object_key"`
|
|
Expires int `json:"expires"`
|
|
}
|
|
|
|
// PresignedURLResponse is the response containing a presigned URL
|
|
type PresignedURLResponse struct {
|
|
URL string `json:"url"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
|
|
// GetPresignedURL requests a presigned URL from the TTS service
|
|
func (c *TTSClient) GetPresignedURL(ctx context.Context, bucket, objectKey string) (string, error) {
|
|
reqBody := PresignedURLRequest{
|
|
Bucket: bucket,
|
|
ObjectKey: objectKey,
|
|
Expires: 3600,
|
|
}
|
|
|
|
body, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/presigned-url", bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", fmt.Errorf("create request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return "", fmt.Errorf("TTS presigned URL request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("TTS presigned URL error (%d): %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result PresignedURLResponse
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return "", fmt.Errorf("parse presigned URL response: %w", err)
|
|
}
|
|
|
|
return result.URL, nil
|
|
}
|
|
|
|
// IsHealthy checks if the TTS service is responsive
|
|
func (c *TTSClient) IsHealthy(ctx context.Context) bool {
|
|
httpReq, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/health", nil)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
return resp.StatusCode == http.StatusOK
|
|
}
|
|
|
|
// ============================================================================
|
|
// Interactive Video TTS Client Methods
|
|
// ============================================================================
|
|
|
|
// SynthesizeSectionsRequest is the request for batch section audio synthesis
|
|
type SynthesizeSectionsRequest struct {
|
|
Sections []SectionAudio `json:"sections"`
|
|
Voice string `json:"voice"`
|
|
ModuleID string `json:"module_id"`
|
|
}
|
|
|
|
// SectionAudio represents one section's text for audio synthesis
|
|
type SectionAudio struct {
|
|
Text string `json:"text"`
|
|
Heading string `json:"heading"`
|
|
}
|
|
|
|
// SynthesizeSectionsResponse is the response from batch section synthesis
|
|
type SynthesizeSectionsResponse struct {
|
|
Sections []SectionResult `json:"sections"`
|
|
TotalDuration float64 `json:"total_duration"`
|
|
}
|
|
|
|
// SectionResult is the result for one section's audio
|
|
type SectionResult struct {
|
|
Heading string `json:"heading"`
|
|
AudioPath string `json:"audio_path"`
|
|
AudioObjectKey string `json:"audio_object_key"`
|
|
Duration float64 `json:"duration"`
|
|
StartTimestamp float64 `json:"start_timestamp"`
|
|
}
|
|
|
|
// GenerateInteractiveVideoRequest is the request for interactive video generation
|
|
type GenerateInteractiveVideoRequest struct {
|
|
Script *NarratorScript `json:"script"`
|
|
Audio *SynthesizeSectionsResponse `json:"audio"`
|
|
ModuleID string `json:"module_id"`
|
|
}
|
|
|
|
// GenerateInteractiveVideoResponse is the response from interactive video generation
|
|
type GenerateInteractiveVideoResponse struct {
|
|
VideoID string `json:"video_id"`
|
|
Bucket string `json:"bucket"`
|
|
ObjectKey string `json:"object_key"`
|
|
DurationSeconds float64 `json:"duration_seconds"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
}
|
|
|
|
// SynthesizeSections calls the TTS service to synthesize audio for multiple sections
|
|
func (c *TTSClient) SynthesizeSections(ctx context.Context, req *SynthesizeSectionsRequest) (*SynthesizeSectionsResponse, error) {
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/synthesize-sections", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("TTS synthesize-sections request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("TTS synthesize-sections error (%d): %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result SynthesizeSectionsResponse
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return nil, fmt.Errorf("parse TTS synthesize-sections response: %w", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// GenerateInteractiveVideo calls the TTS service to create an interactive video with checkpoint slides
|
|
func (c *TTSClient) GenerateInteractiveVideo(ctx context.Context, req *GenerateInteractiveVideoRequest) (*GenerateInteractiveVideoResponse, error) {
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/generate-interactive-video", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("TTS interactive video request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("TTS interactive video error (%d): %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result GenerateInteractiveVideoResponse
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return nil, fmt.Errorf("parse TTS interactive video response: %w", err)
|
|
}
|
|
|
|
return &result, nil
|
|
}
|