audio_pipeline

This commit is contained in:
sanek5g
2026-06-10 17:12:58 +03:00
commit 00ddac5af7
29 changed files with 3297 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /analyse ./cmd/analyse
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=build /analyse /app/analyse
ENTRYPOINT ["/app/analyse"]

View File

@@ -0,0 +1,657 @@
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"strings"
"time"
"unicode/utf8"
"github.com/joho/godotenv"
_ "github.com/jackc/pgx/v5/stdlib"
amqp "github.com/rabbitmq/amqp091-go"
)
func init() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
}
// ── входящее сообщение из очереди analyse (TranscriptionResult от transcribe) ──
type WorkerMessage struct {
TaskID string `json:"task_id"`
Filename string `json:"filename"`
FilePath string `json:"file_path"`
Transcription string `json:"transcription"`
Language string `json:"language"`
Segments []Segment `json:"segments,omitempty"`
Prompts []Prompt `json:"prompts"`
TranscribedAt int64 `json:"transcribed_at"`
}
type Segment struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
}
type Prompt struct {
ID int `json:"id"`
IDSection int `json:"id_section"`
Name string `json:"name"`
Prompt string `json:"prompt"`
DtCreate string `json:"dt_create"`
}
// AnalysisResult — ключ = name промпта, значение = полный JSON-ответ LLM.
type AnalysisResult map[string]any
// ── LLM request/response ──
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Temperature float64 `json:"temperature"`
ResponseFormat struct {
Type string `json:"type"`
} `json:"response_format"`
Messages []chatMessage `json:"messages"`
}
type tokenUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Usage *tokenUsage `json:"usage"`
}
type llmCallResult struct {
Content string
RequestBytes int
ResponseBytes int
Usage *tokenUsage
Duration time.Duration
}
type analysisStats struct {
LLMCalls int
TotalTokens int
PromptTokens int
OutputTokens int
}
// ===================== LLM =====================
var llmHTTPClient = newLLMHTTPClient(150 * time.Second)
func newLLMHTTPClient(totalTimeout time.Duration) *http.Client {
return &http.Client{
Timeout: totalTimeout,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 60 * time.Second,
ResponseHeaderTimeout: 90 * time.Second,
ExpectContinueTimeout: 5 * time.Second,
IdleConnTimeout: 90 * time.Second,
},
}
}
func callLLM(ctx context.Context, apiURL, model, prompt string) (*llmCallResult, error) {
const systemPrompt = "Ты — строгий классификатор звонков. Отвечай только JSON, без пояснений."
reqBody := chatRequest{
Model: model,
Temperature: 0.1,
Messages: []chatMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: prompt},
},
}
reqBody.ResponseFormat.Type = "json_object"
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("YANDEX_API_KEY"))
req.Header.Set("Content-Type", "application/json")
start := time.Now()
resp, err := llmHTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
duration := time.Since(start)
if resp.StatusCode != http.StatusOK {
return &llmCallResult{
RequestBytes: len(jsonData),
ResponseBytes: len(body),
Duration: duration,
}, fmt.Errorf("status %d: %s", resp.StatusCode, truncate(string(body), 500))
}
var result chatResponse
if err := json.Unmarshal(body, &result); err != nil {
return &llmCallResult{
RequestBytes: len(jsonData),
ResponseBytes: len(body),
Duration: duration,
}, err
}
if len(result.Choices) == 0 {
return &llmCallResult{
RequestBytes: len(jsonData),
ResponseBytes: len(body),
Duration: duration,
}, fmt.Errorf("empty response")
}
return &llmCallResult{
Content: result.Choices[0].Message.Content,
RequestBytes: len(jsonData),
ResponseBytes: len(body),
Usage: result.Usage,
Duration: duration,
}, nil
}
func checkYandexAPI(ctx context.Context, apiURL, model string) error {
slog.Info("yandex api check started", "worker", "analyse", "url", apiURL, "model", model)
res, err := callLLM(ctx, apiURL, model, `Ответь только JSON: {"ok":true}`)
if err != nil {
return err
}
attrs := []any{
"worker", "analyse",
"duration_ms", res.Duration.Milliseconds(),
"response_chars", utf8.RuneCountInString(res.Content),
}
if res.Usage != nil {
attrs = append(attrs,
"prompt_tokens", res.Usage.PromptTokens,
"completion_tokens", res.Usage.CompletionTokens,
"total_tokens", res.Usage.TotalTokens,
)
}
slog.Info("yandex api check ok", attrs...)
return nil
}
func logLLMCall(taskID, model, promptName string, promptIndex, promptTotal, attempt, inputChars int, res *llmCallResult, err error) {
attrs := []any{
"worker", "analyse",
"task_id", taskID,
"model", model,
"call_type", "analyse_prompt",
"prompt_name", promptName,
"prompt_index", promptIndex,
"prompt_total", promptTotal,
"attempt", attempt,
"input_chars", inputChars,
}
if res != nil {
attrs = append(attrs,
"duration_ms", res.Duration.Milliseconds(),
"request_bytes", res.RequestBytes,
"response_bytes", res.ResponseBytes,
"response_chars", utf8.RuneCountInString(res.Content),
)
if res.Usage != nil {
attrs = append(attrs,
"prompt_tokens", res.Usage.PromptTokens,
"completion_tokens", res.Usage.CompletionTokens,
"total_tokens", res.Usage.TotalTokens,
)
}
}
if err != nil {
slog.Warn("llm call failed", append(attrs, "error", err)...)
return
}
slog.Info("llm call ok", attrs...)
}
func accumulateUsage(stats *analysisStats, res *llmCallResult) {
stats.LLMCalls++
if res != nil && res.Usage != nil {
stats.TotalTokens += res.Usage.TotalTokens
stats.PromptTokens += res.Usage.PromptTokens
stats.OutputTokens += res.Usage.CompletionTokens
}
}
func buildPromptQuery(transcription string, p Prompt) string {
var b strings.Builder
b.WriteString(p.Prompt)
b.WriteString("\n\n=== ТРАНСКРИПЦИЯ ===\n\"\"\"\n")
b.WriteString(transcription)
b.WriteString("\n\"\"\"")
return b.String()
}
func analysePrompt(ctx context.Context, apiURL, model, transcription string, p Prompt, index, total int, taskID string, stats *analysisStats) (any, error) {
query := buildPromptQuery(transcription, p)
inputChars := utf8.RuneCountInString(query)
res, err := callLLM(ctx, apiURL, model, query)
logLLMCall(taskID, model, p.Name, index, total, 1, inputChars, res, err)
accumulateUsage(stats, res)
if err != nil {
return nil, err
}
var parsed any
if err := json.Unmarshal([]byte(res.Content), &parsed); err != nil {
return nil, fmt.Errorf("parse: %w, resp: %s", err, truncate(res.Content, 300))
}
return parsed, nil
}
func runAnalysis(ctx context.Context, apiURL, model, taskID, transcription string, prompts []Prompt) (AnalysisResult, analysisStats, error) {
stats := analysisStats{}
result := make(AnalysisResult, len(prompts))
valid := make([]Prompt, 0, len(prompts))
for _, p := range prompts {
if p.Name != "" {
valid = append(valid, p)
}
}
total := len(valid)
for i, p := range valid {
value, err := analysePrompt(ctx, apiURL, model, transcription, p, i+1, total, taskID, &stats)
if err != nil {
return nil, stats, fmt.Errorf("%s: %w", p.Name, err)
}
result[p.Name] = value
}
return result, stats, nil
}
// ===================== DB =====================
func saveAnalysis(ctx context.Context, db *sql.DB, task WorkerMessage, analysis []byte) (complete bool, err error) {
metadata, _ := json.Marshal(map[string]any{
"file_path": task.FilePath,
"language": task.Language,
"segments": task.Segments,
"prompts": task.Prompts,
"transcribed_at": task.TranscribedAt,
})
_, err = db.ExecContext(ctx,
`INSERT INTO results (task_id) VALUES ($1) ON CONFLICT (task_id) DO NOTHING`, task.TaskID)
if err != nil {
return false, fmt.Errorf("ensure row: %w", err)
}
err = db.QueryRowContext(ctx, `
UPDATE results
SET analysis = $2::jsonb,
filename = COALESCE(NULLIF($3, ''), filename),
transcription = COALESCE(NULLIF($4, ''), transcription),
metadata = COALESCE($5::jsonb, metadata),
updated_at = now(),
status = CASE WHEN tagging IS NOT NULL THEN 'done' ELSE status END
WHERE task_id = $1
RETURNING (analysis IS NOT NULL AND tagging IS NOT NULL)
`, task.TaskID, string(analysis), task.Filename, task.Transcription, string(metadata)).Scan(&complete)
if err != nil {
return false, fmt.Errorf("update analysis: %w", err)
}
return complete, nil
}
// ===================== MAIN =====================
func loadDotenv() {
path := os.Getenv("DOTENV_PATH")
if path == "" {
return
}
if err := godotenv.Overload(path); err != nil {
slog.Warn("dotenv load failed", "path", path, "error", err)
return
}
slog.Info("dotenv loaded", "path", path)
}
func main() {
loadDotenv()
amqpURL := getEnv("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/")
dbURL := getEnv("DATABASE_URL", "")
token := os.Getenv("YANDEX_API_KEY")
model := os.Getenv("YANDEX_MODEL")
apiURL := getEnv("YANDEX_API_URL", "https://ai.api.cloud.yandex.net/v1/chat/completions")
inputQueue := getEnv("ANALYSE_QUEUE", "analyse")
finalQueue := getEnv("FINAL_QUEUE", "final")
if token == "" {
slog.Error("YANDEX_API_KEY is required")
os.Exit(1)
}
if model == "" {
slog.Error("YANDEX_MODEL is required")
os.Exit(1)
}
if dbURL == "" {
slog.Error("DATABASE_URL is required")
os.Exit(1)
}
slog.Info("config loaded", "worker", "analyse",
"yandex_token", tokenFingerprint(token), "model", model, "api_url", apiURL)
db := mustDB(dbURL)
defer db.Close()
checkCtx, checkCancel := context.WithTimeout(context.Background(), 90*time.Second)
if err := checkYandexAPI(checkCtx, apiURL, model); err != nil {
checkCancel()
slog.Error("yandex api check failed — worker will not start", "worker", "analyse", "error", err)
os.Exit(1)
}
checkCancel()
ch := mustRabbit(amqpURL)
if _, err := ch.QueueDeclare(inputQueue, true, false, false, false, nil); err != nil {
slog.Error("declare queue failed", "queue", inputQueue, "error", err)
os.Exit(1)
}
if _, err := ch.QueueDeclare(finalQueue, true, false, false, false, nil); err != nil {
slog.Error("declare queue failed", "queue", finalQueue, "error", err)
os.Exit(1)
}
ch.Qos(1, 0, false)
msgs, err := ch.Consume(inputQueue, "", false, false, false, false, nil)
if err != nil {
slog.Error("consume failed", "error", err)
os.Exit(1)
}
slog.Info("worker started", "worker", "analyse", "queue", inputQueue, "model", model)
for d := range msgs {
taskStart := time.Now()
var task WorkerMessage
if err := json.Unmarshal(d.Body, &task); err != nil {
slog.Warn("bad message", "worker", "analyse", "delivery_tag", d.DeliveryTag,
"body_bytes", len(d.Body), "error", err)
d.Nack(false, false)
continue
}
promptNames := make([]string, 0, len(task.Prompts))
promptTextChars := 0
for _, p := range task.Prompts {
if p.Name != "" {
promptNames = append(promptNames, p.Name)
promptTextChars += utf8.RuneCountInString(p.Prompt)
}
}
transcriptionChars := utf8.RuneCountInString(task.Transcription)
slog.Info("message received", "worker", "analyse",
"task_id", task.TaskID,
"filename", task.Filename,
"delivery_tag", d.DeliveryTag,
"redelivered", d.Redelivered,
"body_bytes", len(d.Body),
"transcription_chars", transcriptionChars,
"segments", len(task.Segments),
"prompts", len(promptNames),
"prompt_names", promptNames,
"prompt_text_chars", promptTextChars,
"llm_calls_expected", len(promptNames),
)
if d.Redelivered {
slog.Warn("redelivered message skipped — no llm call",
"worker", "analyse", "task_id", task.TaskID,
"delivery_tag", d.DeliveryTag, "prompts", len(promptNames))
d.Nack(false, false)
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
result, stats, err := runAnalysis(ctx, apiURL, model, task.TaskID, task.Transcription, task.Prompts)
if err != nil {
cancel()
slog.Warn("task failed, discarded",
"worker", "analyse", "task_id", task.TaskID,
"llm_calls_done", stats.LLMCalls,
"total_tokens_so_far", stats.TotalTokens,
"error", err)
d.Nack(false, false)
continue
}
analysisJSON, _ := json.Marshal(result)
complete, err := saveAnalysis(ctx, db, task, analysisJSON)
if err != nil {
cancel()
slog.Warn("db save failed, discarded",
"worker", "analyse", "task_id", task.TaskID, "error", err)
d.Nack(false, false)
continue
}
taskAttrs := []any{
"worker", "analyse",
"task_id", task.TaskID,
"llm_calls", stats.LLMCalls,
"total_tokens", stats.TotalTokens,
"prompt_tokens", stats.PromptTokens,
"completion_tokens", stats.OutputTokens,
"duration_ms", time.Since(taskStart).Milliseconds(),
}
if complete {
notifyFinal(ctx, ch, db, finalQueue, task.TaskID, "analyse")
slog.Info("task complete", append(taskAttrs, "was_last", "analyse")...)
} else {
slog.Info("task partial", append(taskAttrs, "waiting_for", "tagging")...)
}
cancel()
d.Ack(false)
}
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
func loadFinalPayload(ctx context.Context, db *sql.DB, taskID string) ([]byte, error) {
var (
filename, transcription, status sql.NullString
analysis, tagging, metadata []byte
createdAt, updatedAt time.Time
)
err := db.QueryRowContext(ctx, `
SELECT filename, transcription, analysis, tagging, metadata, status, created_at, updated_at
FROM results WHERE task_id = $1
`, taskID).Scan(&filename, &transcription, &analysis, &tagging, &metadata, &status, &createdAt, &updatedAt)
if err != nil {
return nil, fmt.Errorf("load result: %w", err)
}
msg := map[string]any{
"task_id": taskID,
"status": status.String,
"created_at": createdAt,
"updated_at": updatedAt,
}
if filename.Valid {
msg["filename"] = filename.String
}
if transcription.Valid {
msg["transcription"] = transcription.String
}
if len(analysis) > 0 {
var v any
if err := json.Unmarshal(analysis, &v); err == nil {
msg["analysis"] = v
}
}
if len(tagging) > 0 {
var v any
if err := json.Unmarshal(tagging, &v); err == nil {
msg["tagging"] = v
}
}
if len(metadata) > 0 {
var meta map[string]any
if err := json.Unmarshal(metadata, &meta); err == nil {
for k, v := range meta {
msg[k] = v
}
}
}
return json.Marshal(msg)
}
func notifyFinal(ctx context.Context, ch *amqp.Channel, db *sql.DB, queue, taskID, worker string) {
body, err := loadFinalPayload(ctx, db, taskID)
if err != nil {
slog.Warn("load final payload failed", "worker", worker, "task_id", taskID, "error", err)
return
}
if err := ch.PublishWithContext(ctx, "", queue, false, false,
amqp.Publishing{
ContentType: "application/json",
Body: body,
DeliveryMode: amqp.Persistent,
}); err != nil {
slog.Warn("publish final failed", "worker", worker, "task_id", taskID, "error", err)
return
}
slog.Info("published final", "worker", worker, "task_id", taskID, "queue", queue, "body_bytes", len(body))
deleteProcessingFile(extractFilePath(body), taskID, worker)
}
func extractFilePath(body []byte) string {
var msg map[string]any
if err := json.Unmarshal(body, &msg); err != nil {
return ""
}
fp, _ := msg["file_path"].(string)
return fp
}
func deleteProcessingFile(filePath, taskID, worker string) {
if filePath == "" {
slog.Warn("processing file not deleted: no file_path", "worker", worker, "task_id", taskID)
return
}
if !strings.Contains(filePath, "/processing/") {
slog.Warn("processing file not deleted: path outside processing", "worker", worker, "task_id", taskID, "path", filePath)
return
}
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
slog.Info("processing file already removed", "worker", worker, "task_id", taskID, "path", filePath)
return
}
slog.Warn("processing file delete failed", "worker", worker, "task_id", taskID, "path", filePath, "error", err)
return
}
slog.Info("processing file deleted", "worker", worker, "task_id", taskID, "path", filePath)
}
func getEnv(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func tokenFingerprint(token string) string {
if len(token) <= 12 {
return "***"
}
return token[:8] + "..." + token[len(token)-4:]
}
func mustDB(url string) *sql.DB {
db, err := sql.Open("pgx", url)
if err != nil {
slog.Error("db open failed", "error", err)
os.Exit(1)
}
db.SetMaxOpenConns(5)
time.Sleep(2 * time.Second) // дать Docker DNS зарегистрировать postgres
for i := 0; i < 60; i++ {
if err = db.Ping(); err == nil {
return db
}
if i < 5 || (i+1)%10 == 0 {
slog.Info("waiting for db", "attempt", i+1, "error", err)
}
time.Sleep(3 * time.Second)
}
slog.Error("db unreachable", "error", err)
os.Exit(1)
return nil
}
func mustRabbit(url string) *amqp.Channel {
var conn *amqp.Connection
var err error
for i := 0; i < 30; i++ {
conn, err = amqp.Dial(url)
if err == nil {
break
}
slog.Info("waiting for rabbit", "attempt", i+1, "error", err)
time.Sleep(2 * time.Second)
}
if err != nil {
slog.Error("rabbit unreachable", "error", err)
os.Exit(1)
}
ch, err := conn.Channel()
if err != nil {
slog.Error("rabbit channel failed", "error", err)
os.Exit(1)
}
return ch
}

18
workers/analyse/go.mod Normal file
View File

@@ -0,0 +1,18 @@
module github.com/yourorg/analyse
go 1.22
require (
github.com/jackc/pgx/v5 v5.5.5
github.com/joho/godotenv v1.5.1
github.com/rabbitmq/amqp091-go v1.9.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)

41
workers/analyse/go.sum Normal file
View File

@@ -0,0 +1,41 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rabbitmq/amqp091-go v1.9.0 h1:qrQtyzB4H8BQgEuJwhmVQqVHB9O4+MNDJCCAcpc3Aoo=
github.com/rabbitmq/amqp091-go v1.9.0/go.mod h1:+jPrT9iY2eLjRaMSRHUhc3z14E/l85kv/f+6luSD3pc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=