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
+65
View File
@@ -0,0 +1,65 @@
package asr
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// Segment represents a transcribed segment with timestamps.
type Segment struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
}
// TranscriptionResult holds the full text and its segments.
type TranscriptionResult struct {
Text string `json:"text"`
Segments []Segment `json:"segments,omitempty"`
}
// ASR defines the interface for Automatic Speech Recognition model operations.
type ASR interface {
// Transcribe sends audio bytes to the ASR model and returns the transcribed text and segments.
Transcribe(ctx context.Context, audioBytes []byte, fileName string) (*TranscriptionResult, error)
GetModelName() string
GetModelID() string
}
// Config holds the configuration needed to create an ASR instance.
type Config struct {
Source types.ModelSource
BaseURL string
ModelName string
APIKey string
ModelID string
Language string // optional: specify language for transcription
// CustomHeaders 允许在调用远程 API 时附加自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
CustomHeaders map[string]string
}
// ConfigFromModel 根据 types.Model 构造 asr.Config。
// 生产路径(从 DB 拉起)和测试连接路径(临时表单)共享这份映射。
// 当前 ASR 不涉及 WeKnoraCloud 凭证,所以签名不含 appID/appSecret。
func ConfigFromModel(m *types.Model) *Config {
if m == nil {
return nil
}
return &Config{
ModelID: m.ID,
APIKey: m.Parameters.APIKey,
BaseURL: m.Parameters.BaseURL,
ModelName: m.Name,
Source: m.Source,
CustomHeaders: m.Parameters.CustomHeaders,
}
}
// NewASR creates an ASR instance based on the provided configuration.
// All ASR vendors use the OpenAI-compatible /v1/audio/transcriptions API.
func NewASR(config *Config) (ASR, error) {
a, err := NewOpenAIASR(config)
return wrapASRLangfuse(a, err)
}
@@ -0,0 +1,36 @@
package asr
import (
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestConfigFromModel(t *testing.T) {
m := &types.Model{
ID: "asr-1",
Name: "whisper-1",
Source: types.ModelSourceRemote,
Parameters: types.ModelParameters{
BaseURL: "https://api.example.com/v1",
APIKey: "sk",
CustomHeaders: map[string]string{"X": "y"},
},
}
cfg := ConfigFromModel(m)
if cfg == nil || cfg.ModelID != "asr-1" || cfg.ModelName != "whisper-1" {
t.Fatalf("identity mismatch: %+v", cfg)
}
if cfg.BaseURL != "https://api.example.com/v1" || cfg.APIKey != "sk" {
t.Errorf("connection fields mismatch: %+v", cfg)
}
if cfg.CustomHeaders["X"] != "y" {
t.Errorf("CustomHeaders not propagated: %+v", cfg.CustomHeaders)
}
}
func TestConfigFromModel_Nil(t *testing.T) {
if got := ConfigFromModel(nil); got != nil {
t.Fatalf("expected nil, got %+v", got)
}
}
+79
View File
@@ -0,0 +1,79 @@
package asr
import (
"context"
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
)
// langfuseASR wraps an ASR implementation and reports each Transcribe call
// as a Langfuse generation observation. Audio bytes are not uploaded — we
// record file name, audio size and duration (derived from segment end times)
// so usage can be billed by second/minute, which is how most ASR providers
// price their services.
type langfuseASR struct {
inner ASR
}
func (l *langfuseASR) GetModelName() string { return l.inner.GetModelName() }
func (l *langfuseASR) GetModelID() string { return l.inner.GetModelID() }
func (l *langfuseASR) Transcribe(ctx context.Context, audioBytes []byte, fileName string) (*TranscriptionResult, error) {
mgr := langfuse.GetManager()
if !mgr.Enabled() {
return l.inner.Transcribe(ctx, audioBytes, fileName)
}
genCtx, gen := mgr.StartGeneration(ctx, langfuse.GenerationOptions{
Name: "asr.transcribe",
Model: l.inner.GetModelName(),
Input: map[string]interface{}{
"file_name": fileName,
"audio_size": len(audioBytes),
},
Metadata: map[string]interface{}{
"model_id": l.inner.GetModelID(),
"audio_size": len(audioBytes),
},
})
result, err := l.inner.Transcribe(genCtx, audioBytes, fileName)
output := map[string]interface{}{}
var duration float64
if result != nil {
output["text"] = result.Text
output["segment_count"] = len(result.Segments)
if n := len(result.Segments); n > 0 {
duration = result.Segments[n-1].End
output["duration_seconds"] = duration
}
}
// ASR is billed per second; we emit the audio duration (when available)
// in the "Output" side of usage so users can configure per-model pricing
// in Langfuse (e.g. $0.006 per minute for Whisper-1).
var usage *langfuse.TokenUsage
if duration > 0 {
seconds := int(duration + 0.5)
usage = &langfuse.TokenUsage{
Output: seconds,
Total: seconds,
Unit: "SECONDS",
}
}
gen.Finish(output, usage, err)
return result, err
}
// wrapASRLangfuse applies the Langfuse decorator when the manager is enabled.
func wrapASRLangfuse(a ASR, err error) (ASR, error) {
if err != nil || a == nil {
return a, err
}
if !langfuse.GetManager().Enabled() {
return a, nil
}
return &langfuseASR{inner: a}, nil
}
+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
}
+25
View File
@@ -0,0 +1,25 @@
package asr
import (
"fmt"
"net/http"
"time"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
func validateASRBaseURL(baseURL string) error {
if baseURL == "" {
return nil
}
if err := secutils.ValidateURLForSSRF(baseURL); err != nil {
return fmt.Errorf("base URL SSRF check failed: %w", err)
}
return nil
}
func newASRHTTPClient(timeout time.Duration) *http.Client {
cfg := secutils.DefaultSSRFSafeHTTPClientConfig()
cfg.Timeout = timeout
return secutils.NewSSRFSafeHTTPClient(cfg)
}
@@ -0,0 +1,26 @@
package asr
import (
"testing"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
func withASRSSRFWhitelist(t *testing.T, raw string) {
t.Helper()
t.Setenv("SSRF_WHITELIST", raw)
secutils.ResetSSRFWhitelistForTest()
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
}
func TestOpenAIASRRejectsInternalBaseURL(t *testing.T) {
withASRSSRFWhitelist(t, "")
_, err := NewOpenAIASR(&Config{
BaseURL: "http://169.254.169.254/latest/meta-data/",
ModelName: "asr-test",
})
if err == nil {
t.Fatalf("NewOpenAIASR returned nil error for blocked internal BaseURL")
}
}