You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
466 lines
16 KiB
466 lines
16 KiB
package echomeet
|
|
|
|
import (
|
|
"yunyan/comm"
|
|
"yunyan/lego/sys/log"
|
|
"yunyan/lego/sys/mysql"
|
|
"yunyan/pb"
|
|
"yunyan/sys/doubao"
|
|
"yunyan/sys/google/gemini"
|
|
"yunyan/sys/openai"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// 会议记录服务配置的运行时消费(数据模型见 comm/echomeetsvc.go)。
|
|
//
|
|
// 启动流程(providersComp.Init 末尾调用 loadOrSeedSvcConfig):
|
|
// 1. 建表(应用业务库,全局一份);
|
|
// 2. 库空 → 把当前 yaml(Options) 的四套服务商 seed 成识别/翻译/总结行(密钥加密落库);
|
|
// 3. 加载识别/翻译两类的启用清单,按 priority 升序,供选择逻辑使用。
|
|
//
|
|
// 选择逻辑:
|
|
// - 识别 selectASR(lang):按优先级取第一个「支持该语言」的服务(支持语言以库里 SupportLangs 为准,
|
|
// 为空则回退各 Transcriber.SupportsLanguage)——这修复了「西语被强路由到不支持西语的阿里 paraformer-v2」。
|
|
// - 翻译 selectMT(from,to):按优先级取第一个启用的服务。
|
|
//
|
|
// 凭据托管:seed 会把 yaml 里的 AK/SK/Token 等一并写入库(密钥字段 AES 落库),
|
|
// 便于后台面板管理;SDK 侧改读库值将在后续阶段接入(当前仍由 yaml 初始化,行为不变)。
|
|
|
|
// paraformerV2Langs 阿里 DashScope paraformer-v2 录音文件识别真实支持的语种(BCP-47)。
|
|
// 依据阿里官方文档:中文(及方言)/英文/日语/韩语/德语/法语/俄语——**不含西班牙语等**。
|
|
// 作为阿里识别行 SupportLangs 的 seed 默认值;用户可在后台面板增删。
|
|
var paraformerV2Langs = []string{
|
|
"zh-CN", "zh-TW", "zh-HK", "yue-HK",
|
|
"en-US", "en-GB", "ja-JP", "ko-KR",
|
|
"de-DE", "fr-FR", "ru-RU", "ru-UA",
|
|
}
|
|
|
|
// providerToEnum 把库里的 provider 字符串(识别/翻译类)映射回 TranscribeService 枚举。
|
|
func providerToEnum(provider string) pb.TranscribeService {
|
|
switch provider {
|
|
case "bytedance":
|
|
return pb.TranscribeService_TranscribeService_ByteDance
|
|
case "ali":
|
|
return pb.TranscribeService_TranscribeService_Ali
|
|
case "google":
|
|
return pb.TranscribeService_TranscribeService_Google
|
|
case "microsoft":
|
|
return pb.TranscribeService_TranscribeService_Microsoft
|
|
default:
|
|
return pb.TranscribeService_TranscribeService_Unknown
|
|
}
|
|
}
|
|
|
|
// loadOrSeedSvcConfig 建表 + 库空则 seed + 加载识别/翻译优先级清单。best-effort,失败仅记录日志。
|
|
func (this *providersComp) loadOrSeedSvcConfig(opt *Options) {
|
|
db := mysql.GetSys()
|
|
if db == nil {
|
|
return
|
|
}
|
|
if err := comm.EnsureEchoSvcTable(db); err != nil {
|
|
this.module.Errorf("echomeet svcconfig 建表失败 err:%v", err)
|
|
return
|
|
}
|
|
rows, err := comm.LoadEchoSvcConfigs(db)
|
|
if err != nil {
|
|
this.module.Errorf("echomeet svcconfig 读取失败 err:%v", err)
|
|
return
|
|
}
|
|
if len(rows) == 0 {
|
|
if rows, err = this.seedSvcConfig(db, opt); err != nil {
|
|
this.module.Errorf("echomeet svcconfig seed 失败 err:%v", err)
|
|
return
|
|
}
|
|
this.module.Infof("echomeet svcconfig 首次 seed 完成 rows:%d", len(rows))
|
|
}
|
|
|
|
// 统一把 Values 解密为明文供运行时消费(seed 返回的本就是明文,Decrypt 对明文会失败并保留原值,安全)。
|
|
encKey := os.Getenv("FIELD_ENCRYPT_KEY")
|
|
for _, r := range rows {
|
|
r.Values = comm.DecryptEchoValues(encKey, r.Category, r.Provider, r.Values)
|
|
}
|
|
|
|
// 分类归集 + 优先级排序(仅启用项)。
|
|
asr := make([]*comm.EchoSvcConfig, 0)
|
|
mt := make([]*comm.EchoSvcConfig, 0)
|
|
llm := make([]*comm.EchoSvcConfig, 0)
|
|
for _, r := range rows {
|
|
if !r.Enabled {
|
|
continue
|
|
}
|
|
switch r.Category {
|
|
case comm.EchoSvcCatASR:
|
|
asr = append(asr, r)
|
|
case comm.EchoSvcCatMT:
|
|
mt = append(mt, r)
|
|
case comm.EchoSvcCatLLM:
|
|
llm = append(llm, r)
|
|
}
|
|
}
|
|
sort.SliceStable(asr, func(i, j int) bool { return asr[i].Priority < asr[j].Priority })
|
|
sort.SliceStable(mt, func(i, j int) bool { return mt[i].Priority < mt[j].Priority })
|
|
sort.SliceStable(llm, func(i, j int) bool { return llm[i].Priority < llm[j].Priority })
|
|
this.asrOrder = asr
|
|
this.mtOrder = mt
|
|
this.llmOrder = llm
|
|
|
|
// 用库值(凭据来自库)重建各服务商的转写/翻译/总结实例;总结按条目建实例支持一商多模型。
|
|
this.buildProvidersFromDB(rows, opt)
|
|
|
|
this.svcLoaded = true
|
|
this.module.Infof("echomeet svcconfig 加载完成 识别:%d 翻译:%d 总结:%d", len(asr), len(mt), len(llm))
|
|
}
|
|
|
|
// buildProvidersFromDB 用库里的(已解密)配置重建 provider:
|
|
// - 识别/翻译:逐服务商用库值重新初始化底层 sys(凭据托管到后台的落点),覆盖 enum→provider 映射;某项缺行则回退 yaml。
|
|
// - 总结:每条启用的 LLM 行建一个模型实例(key=条目 id),客户端按 id 选择;同时按源服务商回填 enum→summarizer 保持旧调用兼容。
|
|
func (this *providersComp) buildProvidersFromDB(rows []*comm.EchoSvcConfig, opt *Options) {
|
|
byId := make(map[string]*comm.EchoSvcConfig, len(rows))
|
|
for _, r := range rows {
|
|
byId[r.Id] = r
|
|
}
|
|
type provSeed struct {
|
|
name string
|
|
enum pb.TranscribeService
|
|
po ProviderOptions
|
|
}
|
|
provs := []provSeed{
|
|
{"bytedance", pb.TranscribeService_TranscribeService_ByteDance, opt.ByteDance},
|
|
{"ali", pb.TranscribeService_TranscribeService_Ali, opt.Ali},
|
|
{"google", pb.TranscribeService_TranscribeService_Google, opt.Google},
|
|
{"microsoft", pb.TranscribeService_TranscribeService_Microsoft, opt.Microsoft},
|
|
}
|
|
for _, p := range provs {
|
|
po := p.po // 缺行回退 yaml
|
|
if r := byId["asr_"+p.name]; r != nil {
|
|
po.Transcribe = toIfaceMap(r.Values)
|
|
if cb := r.Values["Callback"]; cb != "" {
|
|
po.Callback = cb
|
|
}
|
|
}
|
|
if r := byId["mt_"+p.name]; r != nil {
|
|
po.Translate = toIfaceMap(r.Values)
|
|
}
|
|
if r := byId["llm_"+p.name]; r != nil {
|
|
po.Summary = summaryFromValues(r.Provider, r.Values)
|
|
}
|
|
t, tr, sm := buildProviderSet(p.name, po)
|
|
if t != nil {
|
|
this.transcribers[p.enum] = t
|
|
}
|
|
if tr != nil {
|
|
this.translators[p.enum] = tr
|
|
}
|
|
if sm != nil {
|
|
this.summarizers[p.enum] = sm
|
|
}
|
|
}
|
|
// 总结模型实例(一个服务商可多条不同模型)。
|
|
this.summarizersById = make(map[string]Summarizer)
|
|
for _, r := range rows {
|
|
if r.Category != comm.EchoSvcCatLLM || !r.Enabled {
|
|
continue
|
|
}
|
|
if s := buildSummarizer(r.Provider, r.Values); s != nil {
|
|
this.summarizersById[r.Id] = s
|
|
}
|
|
}
|
|
}
|
|
|
|
// buildProviderSet 按服务商名调对应的成套初始化(从 DB 值构造的 ProviderOptions)。
|
|
func buildProviderSet(name string, po ProviderOptions) (Transcriber, Translator, Summarizer) {
|
|
switch name {
|
|
case "bytedance":
|
|
return newByteDanceProviders(po)
|
|
case "ali":
|
|
return newAliProviders(po)
|
|
case "google":
|
|
return newGoogleProviders(po)
|
|
case "microsoft":
|
|
return newMicrosoftProviders(po)
|
|
}
|
|
return nil, nil, nil
|
|
}
|
|
|
|
// buildSummarizer 按总结条目的 provider(doubao/openai/gemini) + 值构造一个总结模型实例。
|
|
func buildSummarizer(provider string, v map[string]string) Summarizer {
|
|
switch provider {
|
|
case "doubao":
|
|
sys, err := doubao.NewSys(doubao.SetApikey(v["Apikey"]), doubao.SetModel(v["Model"]))
|
|
if err != nil {
|
|
log.Errorf("echomeet buildSummarizer doubao err:%v", err)
|
|
return nil
|
|
}
|
|
return &doubaoSummarizer{sys: sys}
|
|
case "openai":
|
|
sys, err := openai.NewSys(openai.SetBaseURL(v["BaseURL"]), openai.SetToken(v["Token"]), openai.SetModel(v["Model"]), openai.SetMaxfunccall(1))
|
|
if err != nil {
|
|
log.Errorf("echomeet buildSummarizer openai err:%v", err)
|
|
return nil
|
|
}
|
|
return &aliSummarizer{sys: sys} // aliSummarizer 是通用 OpenAI 兼容端点包装
|
|
case "gemini":
|
|
sys, err := gemini.NewSys(gemini.SetApikey(v["Apikey"]), gemini.SetModel(v["Model"]), gemini.SetEndpoint(v["Endpoint"]))
|
|
if err != nil {
|
|
log.Errorf("echomeet buildSummarizer gemini err:%v", err)
|
|
return nil
|
|
}
|
|
return &geminiSummarizer{sys: sys}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// summaryFromValues 把库 Values 还原成 SummaryOptions(summaryToValues 的逆过程)。
|
|
func summaryFromValues(provider string, v map[string]string) SummaryOptions {
|
|
switch provider {
|
|
case "doubao":
|
|
return SummaryOptions{Apikey: v["Apikey"], Model: v["Model"]}
|
|
case "gemini":
|
|
return SummaryOptions{Apikey: v["Apikey"], Model: v["Model"], Endpoint: v["Endpoint"]}
|
|
case "openai":
|
|
return SummaryOptions{BaseURL: v["BaseURL"], Token: v["Token"], Model: v["Model"]}
|
|
}
|
|
return SummaryOptions{}
|
|
}
|
|
|
|
// toIfaceMap 把 map[string]string 转成底层 sys OnInit 需要的 map[string]interface{}。
|
|
func toIfaceMap(m map[string]string) map[string]interface{} {
|
|
out := make(map[string]interface{}, len(m))
|
|
for k, v := range m {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetSummarizerById 按条目 id 取总结模型实例;空或未命中回退优先级最高的启用条目。
|
|
func (this *providersComp) GetSummarizerById(id string) Summarizer {
|
|
if id != "" && this.summarizersById != nil {
|
|
if s, ok := this.summarizersById[id]; ok && s != nil {
|
|
return s
|
|
}
|
|
}
|
|
if len(this.llmOrder) > 0 && this.summarizersById != nil {
|
|
return this.summarizersById[this.llmOrder[0].Id]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DefaultSummaryId 返回优先级最高的启用总结条目 id(无则空)。
|
|
func (this *providersComp) DefaultSummaryId() string {
|
|
if len(this.llmOrder) > 0 {
|
|
return this.llmOrder[0].Id
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ListSummaryModels 返回按优先级排序的启用总结条目(供客户端选择)。
|
|
func (this *providersComp) ListSummaryModels() []*comm.EchoSvcConfig {
|
|
return this.llmOrder
|
|
}
|
|
|
|
// seedSvcConfig 把 yaml(Options) 的四套服务商映射为识别/翻译/总结行并落库;返回落库后的行(Values 为明文,供内存直接用)。
|
|
func (this *providersComp) seedSvcConfig(db mysql.ISys, opt *Options) ([]*comm.EchoSvcConfig, error) {
|
|
encKey := os.Getenv("FIELD_ENCRYPT_KEY")
|
|
now := time.Now().Unix()
|
|
// provider 顺序即默认优先级(字节→阿里→Google→微软,留间隔便于后台调序)。
|
|
type provSeed struct {
|
|
provider string
|
|
prio int32
|
|
po ProviderOptions
|
|
}
|
|
provs := []provSeed{
|
|
{"bytedance", 10, opt.ByteDance},
|
|
{"ali", 20, opt.Ali},
|
|
{"google", 30, opt.Google},
|
|
{"microsoft", 40, opt.Microsoft},
|
|
}
|
|
|
|
rows := make([]*comm.EchoSvcConfig, 0, len(provs)*3)
|
|
add := func(r *comm.EchoSvcConfig) {
|
|
r.Createtime, r.Updatetime = now, now
|
|
rows = append(rows, r)
|
|
}
|
|
for _, p := range provs {
|
|
// —— 识别 ——
|
|
asrVals := mapToStrings(p.po.Transcribe)
|
|
if p.po.Callback != "" {
|
|
asrVals["Callback"] = p.po.Callback
|
|
}
|
|
add(&comm.EchoSvcConfig{
|
|
Id: "asr_" + p.provider, Category: comm.EchoSvcCatASR, Provider: p.provider,
|
|
Name: providerDisplayName(comm.EchoSvcCatASR, p.provider), Priority: p.prio,
|
|
Enabled: hasCreds(asrVals), SupportLangs: seedSupportLangs(p.provider), Values: asrVals,
|
|
})
|
|
// —— 翻译 ——
|
|
mtVals := mapToStrings(p.po.Translate)
|
|
add(&comm.EchoSvcConfig{
|
|
Id: "mt_" + p.provider, Category: comm.EchoSvcCatMT, Provider: p.provider,
|
|
Name: providerDisplayName(comm.EchoSvcCatMT, p.provider), Priority: p.prio,
|
|
Enabled: hasCreds(mtVals), Values: mtVals,
|
|
})
|
|
// —— 总结(文本大模型)——
|
|
if llmProvider, llmVals := summaryToValues(p.provider, p.po.Summary); llmVals != nil {
|
|
add(&comm.EchoSvcConfig{
|
|
Id: "llm_" + p.provider, Category: comm.EchoSvcCatLLM, Provider: llmProvider,
|
|
Name: summaryDisplayName(p.provider, p.po.Summary.Model), Priority: p.prio,
|
|
Enabled: p.po.Summary.Model != "", Values: llmVals,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 落库:密钥字段加密。内存返回明文副本。
|
|
for _, r := range rows {
|
|
enc := *r
|
|
enc.Values = comm.EncryptEchoValues(encKey, r.Category, r.Provider, r.Values)
|
|
if err := db.Insert(comm.TableEchomeetSvcConfig, &enc); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// selectASR 按优先级 + 支持语言选识别服务;无命中回退旧的 ResolveServiceType。
|
|
func (this *providersComp) selectASR(lang string) pb.TranscribeService {
|
|
if this.svcLoaded {
|
|
for _, r := range this.asrOrder {
|
|
if this.asrSupportsLang(r, lang) {
|
|
if svc := providerToEnum(r.Provider); svc != pb.TranscribeService_TranscribeService_Unknown {
|
|
return svc
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 回退:沿用旧的按语言自动路由(字节支持则字节,否则阿里)。
|
|
return this.ResolveServiceType(pb.TranscribeService_TranscribeService_Auto, lang)
|
|
}
|
|
|
|
// selectMT 按优先级选翻译服务;无命中回退到给定识别服务(保持三段同源的旧行为)。
|
|
func (this *providersComp) selectMT(from, to string, fallback pb.TranscribeService) pb.TranscribeService {
|
|
if this.svcLoaded {
|
|
for _, r := range this.mtOrder {
|
|
if svc := providerToEnum(r.Provider); svc != pb.TranscribeService_TranscribeService_Unknown {
|
|
if _, ok := this.translators[svc]; ok {
|
|
return svc
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// asrSupportsLang 判断某识别行是否支持该语言:库里 SupportLangs 非空以其为准,否则回退 Transcriber.SupportsLanguage。
|
|
func (this *providersComp) asrSupportsLang(r *comm.EchoSvcConfig, lang string) bool {
|
|
if strings.TrimSpace(r.SupportLangs) != "" {
|
|
return langInCSV(r.SupportLangs, lang)
|
|
}
|
|
if t, ok := this.transcribers[providerToEnum(r.Provider)]; ok && t != nil {
|
|
return t.SupportsLanguage(lang)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ==================== seed 辅助 ====================
|
|
|
|
// mapToStrings 把 yaml 的 map[string]interface{} 值统一转成字符串。
|
|
func mapToStrings(m map[string]interface{}) map[string]string {
|
|
out := make(map[string]string, len(m))
|
|
for k, v := range m {
|
|
if v == nil {
|
|
continue
|
|
}
|
|
if s, ok := v.(string); ok {
|
|
out[k] = s
|
|
} else {
|
|
out[k] = fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// summaryToValues 把结构化的 SummaryOptions 转成库 Values,并返回该总结条目的 provider(doubao/openai/gemini)。
|
|
func summaryToValues(sourceProvider string, s SummaryOptions) (provider string, values map[string]string) {
|
|
switch sourceProvider {
|
|
case "bytedance":
|
|
return "doubao", map[string]string{"Apikey": s.Apikey, "Model": s.Model}
|
|
case "google":
|
|
return "gemini", map[string]string{"Apikey": s.Apikey, "Model": s.Model, "Endpoint": s.Endpoint}
|
|
case "ali", "microsoft":
|
|
return "openai", map[string]string{"BaseURL": s.BaseURL, "Token": s.Token, "Model": s.Model}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// hasCreds 判断一组配置是否有实质凭据(任一非空即视为已配)。
|
|
func hasCreds(vals map[string]string) bool {
|
|
for k, v := range vals {
|
|
if k == "Region" || k == "Endpoint" || k == "Callback" {
|
|
continue // 这些是环境/地址项,不算凭据
|
|
}
|
|
if strings.TrimSpace(v) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// seedSupportLangs 识别行的支持语言默认 seed 值。
|
|
func seedSupportLangs(provider string) string {
|
|
switch provider {
|
|
case "ali":
|
|
// 阿里 paraformer-v2 真实支持集(不含西语等)——从根上避免西语被路由到阿里后转写空。
|
|
return strings.Join(paraformerV2Langs, ",")
|
|
case "bytedance":
|
|
return strings.Join(byteDanceSupportLangs(), ",")
|
|
default:
|
|
return "" // Google/微软支持较广,留空表示按代码默认(SupportsLanguage=true)
|
|
}
|
|
}
|
|
|
|
// byteDanceSupportLangs 从 bcp47ToByteDance 收集字节支持的 BCP-47 语种。
|
|
func byteDanceSupportLangs() []string {
|
|
out := make([]string, 0, len(bcp47ToByteDance))
|
|
for k := range bcp47ToByteDance {
|
|
if k != "" {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// langInCSV 判断 lang 是否在逗号分隔集合内(精确匹配,或按基础子标签匹配,如 es 命中 es-ES)。
|
|
func langInCSV(csv, lang string) bool {
|
|
if lang == "" {
|
|
return false
|
|
}
|
|
base := baseLang(lang)
|
|
for _, item := range strings.Split(csv, ",") {
|
|
item = strings.TrimSpace(item)
|
|
if item == "" {
|
|
continue
|
|
}
|
|
if strings.EqualFold(item, lang) || strings.EqualFold(baseLang(item), base) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func providerDisplayName(category int32, provider string) string {
|
|
if def := comm.FindEchoSvcDef(category, provider); def != nil {
|
|
return def.Name
|
|
}
|
|
return provider
|
|
}
|
|
|
|
func summaryDisplayName(sourceProvider, model string) string {
|
|
name := providerDisplayName(comm.EchoSvcCatASR, sourceProvider)
|
|
if model != "" {
|
|
return name + "(" + model + ")"
|
|
}
|
|
return name
|
|
}
|
|
|