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 }