WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
package asr
import (
"bytes"
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
openai "github.com/sashabaranov/go-openai"
)
const (
asrDefaultTimeout = 300 * time.Second // audio transcription can be slow
)
// OpenAIASR implements ASR via an OpenAI-compatible audio transcriptions API.
type OpenAIASR struct {
modelName string
modelID string
client *openai.Client
baseURL string
language string
}
// NewOpenAIASR creates an OpenAI-compatible ASR instance.
func NewOpenAIASR(config *Config) (*OpenAIASR, error) {
if err := validateASRBaseURL(config.BaseURL); err != nil {
return nil, err
}
apiCfg := openai.DefaultConfig(config.APIKey)
if config.BaseURL != "" {
apiCfg.BaseURL = config.BaseURL
}
httpClient := newASRHTTPClient(asrDefaultTimeout)
// 注入用户自定义 HTTP header(类似 OpenAI Python SDK 的 extra_headers)
if len(config.CustomHeaders) > 0 {
apiCfg.HTTPClient = secutils.WrapHTTPClientWithHeaders(httpClient, config.CustomHeaders)
} else {
apiCfg.HTTPClient = httpClient
}
return &OpenAIASR{
modelName: config.ModelName,
modelID: config.ModelID,
client: openai.NewClientWithConfig(apiCfg),
baseURL: config.BaseURL,
language: config.Language,
}, nil
}
// Transcribe sends audio bytes to the OpenAI-compatible audio transcriptions API.
func (s *OpenAIASR) Transcribe(ctx context.Context, audioBytes []byte, fileName string) (*TranscriptionResult, error) {
if len(audioBytes) == 0 {
return nil, fmt.Errorf("audio bytes are empty")
}
// Ensure fileName has an extension for proper MIME type detection
if fileName == "" {
fileName = "audio.mp3"
}
logger.Infof(ctx, "[ASR] Calling OpenAI-compatible transcription API, model=%s, baseURL=%s, audioSize=%d, file=%s",
s.modelName, s.baseURL, len(audioBytes), fileName)
req := openai.AudioRequest{
Model: s.modelName,
FilePath: fileName,
Reader: bytes.NewReader(audioBytes),
Format: openai.AudioResponseFormatVerboseJSON,
}
if s.language != "" {
req.Language = s.language
}
resp, err := s.client.CreateTranscription(ctx, req)
if err != nil {
return nil, fmt.Errorf("ASR transcription request failed: %w", err)
}
jsonData, err := json.Marshal(resp)
if err != nil {
return nil, fmt.Errorf("marshal transcription response: %w", err)
}
logger.Debugf(ctx, "[ASR] Transcription response: %s", string(jsonData))
text := strings.TrimSpace(resp.Text)
logger.Infof(ctx, "[ASR] Transcription completed, text length=%d", len(text))
var segments []Segment
for _, s := range resp.Segments {
segments = append(segments, Segment{
Start: s.Start,
End: s.End,
Text: strings.TrimSpace(s.Text),
})
}
return &TranscriptionResult{
Text: text,
Segments: segments,
}, nil
}
func (s *OpenAIASR) GetModelName() string { return s.modelName }
func (s *OpenAIASR) GetModelID() string { return s.modelID }
// DetectAudioFormat returns a file extension hint for the given audio bytes.
func DetectAudioFormat(data []byte, fileName string) string {
if fileName != "" {
ext := strings.ToLower(filepath.Ext(fileName))
if ext != "" {
return ext
}
}
// Try to detect from magic bytes
if len(data) >= 4 {
// MP3: starts with ID3 or 0xFF 0xFB
if data[0] == 'I' && data[1] == 'D' && data[2] == '3' {
return ".mp3"
}
if data[0] == 0xFF && (data[1]&0xE0) == 0xE0 {
return ".mp3"
}
// FLAC: starts with "fLaC"
if data[0] == 'f' && data[1] == 'L' && data[2] == 'a' && data[3] == 'C' {
return ".flac"
}
// OGG: starts with "OggS"
if data[0] == 'O' && data[1] == 'g' && data[2] == 'g' && data[3] == 'S' {
return ".ogg"
}
// WAV: starts with "RIFF"
if data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' {
return ".wav"
}
}
// M4A: check for ftyp box
if len(data) >= 8 && data[4] == 'f' && data[5] == 't' && data[6] == 'y' && data[7] == 'p' {
return ".m4a"
}
return ".mp3" // default
}