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

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
}