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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user