Files
EchoChat/backend/go-service/app/transcribe/service/openai_compat_client.go
2026-05-19 14:34:54 +08:00

182 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package service 提供 transcribe 模块的业务服务
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"path"
"strings"
"time"
"github.com/echochat/backend/app/transcribe/dao"
)
// OpenAICompatibleClient 实现 OpenAI 兼容协议的 STT 调用
//
// 标准端点POST {base_url}/audio/transcriptions
// 形式multipart/form-data
// - file: 音频文件mp3/wav/m4a/mp4/...
// - model: 模型 code如 whisper-1 / qwen-audio-asr-flash
// - response_format: verbose_json拿 segments/ json仅 text
// - language: 可选,提示语言以加速识别
//
// 阿里云 DashScope / DeepSeek / OpenAI / 智谱(部分)/ 通义千问 都遵循该协议。
//
// 调用超时:默认 5 分钟(覆盖大部分 ≤30min 会议)。超时由调用方通过 ctx 控制更精细。
type OpenAICompatibleClient struct {
httpClient *http.Client
}
// NewOpenAICompatibleClient 创建实例
func NewOpenAICompatibleClient() *OpenAICompatibleClient {
return &OpenAICompatibleClient{
httpClient: &http.Client{Timeout: 5 * time.Minute},
}
}
// TranscribeResult openai 兼容响应verbose_json 模式)
//
// 字段非全列:仅取本服务关心的部分。多余字段被丢弃。
type TranscribeResult struct {
Text string `json:"text"`
Language string `json:"language"`
Duration float64 `json:"duration"`
Segments []TranscribeSegment `json:"segments"`
}
// TranscribeSegment 单段时间轴文字verbose_json
type TranscribeSegment struct {
ID int `json:"id"`
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
}
// Transcribe 调用 STT
//
// 流程:
// 1. 从 fileURL 下载音频字节HTTP GET复用 httpClient timeout
// 2. 构造 multipart 请求体
// 3. POST {base_url}/audio/transcriptions附 Authorization: Bearer {api_key}
// 4. 解析 JSON 响应
//
// 参数:
// - fileURL录制文件公网/内网可达 URLmeeting_recordings.file_url
// - language可选传 "" 让模型自检
func (c *OpenAICompatibleClient) Transcribe(ctx context.Context, cfg *dao.STTConfig, fileURL, language string) (*TranscribeResult, error) {
if cfg == nil {
return nil, fmt.Errorf("STT config is nil")
}
if fileURL == "" {
return nil, fmt.Errorf("file_url is empty")
}
// 1) 下载音频
audioBytes, filename, err := c.downloadAudio(ctx, fileURL)
if err != nil {
return nil, fmt.Errorf("下载录制文件失败: %w", err)
}
// 2) 拼装 multipart
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
filePart, err := writer.CreateFormFile("file", filename)
if err != nil {
return nil, fmt.Errorf("创建 multipart file 字段失败: %w", err)
}
if _, err := filePart.Write(audioBytes); err != nil {
return nil, fmt.Errorf("写入 multipart file 内容失败: %w", err)
}
if err := writer.WriteField("model", cfg.Model.ModelCode); err != nil {
return nil, fmt.Errorf("写入 model 字段失败: %w", err)
}
if err := writer.WriteField("response_format", "verbose_json"); err != nil {
return nil, fmt.Errorf("写入 response_format 字段失败: %w", err)
}
if language != "" {
_ = writer.WriteField("language", language)
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("关闭 multipart writer 失败: %w", err)
}
// 3) 构造请求
endpoint := strings.TrimRight(cfg.Provider.BaseURL, "/") + "/audio/transcriptions"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
if err != nil {
return nil, fmt.Errorf("构造 STT 请求失败: %w", err)
}
req.Header.Set("Authorization", "Bearer "+cfg.Key.APIKey)
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("调用 STT 接口失败: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 STT 响应失败: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// 截断响应体,避免日志爆炸
preview := string(respBytes)
if len(preview) > 300 {
preview = preview[:300] + "..."
}
return nil, fmt.Errorf("STT 接口返回 %d: %s", resp.StatusCode, preview)
}
// 4) 解析
var result TranscribeResult
if err := json.Unmarshal(respBytes, &result); err != nil {
preview := string(respBytes)
if len(preview) > 300 {
preview = preview[:300] + "..."
}
return nil, fmt.Errorf("解析 STT 响应失败: %v, body=%s", err, preview)
}
if result.Text == "" && len(result.Segments) == 0 {
// 部分供应商在 response_format=json 模式只返回 text这里没拿到任何结果视为异常
return nil, fmt.Errorf("STT 响应内容为空")
}
return &result, nil
}
// downloadAudio 从给定 URL 拉取音频内容,返回字节流 + 推断的文件名
//
// 文件名仅作为 multipart 的 filename 参数,主要决定 Content-Type 推断;
// 取 URL path 末段,无后缀则默认 recording.webm与 media-server 实际产物一致)。
func (c *OpenAICompatibleClient) downloadAudio(ctx context.Context, fileURL string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
if err != nil {
return nil, "", err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", fmt.Errorf("下载失败 status=%d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
filename := path.Base(req.URL.Path)
if filename == "" || filename == "/" || !strings.Contains(filename, ".") {
filename = "recording.mp4"
}
return data, filename, nil
}