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.
364 lines
12 KiB
364 lines
12 KiB
package echomeet
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
|
|
"yunyan/comm"
|
|
"yunyan/lego/core"
|
|
"yunyan/lego/core/cbase"
|
|
"yunyan/lego/sys/cron"
|
|
"yunyan/pb"
|
|
natssys "yunyan/sys/nats"
|
|
|
|
gonats "github.com/nats-io/nats.go"
|
|
)
|
|
|
|
// providersComp 会议记录服务编排消费组件。
|
|
//
|
|
// 不再是「yaml 四套凭据 + 一会一服务商」:三段(识别/翻译/总结)各自由后台编排
|
|
// (console 共享库 echomeet_orch + 服务池 svc_config)决定——
|
|
// - 识别/翻译:按 Priority 升序 + 语言支持过滤选路,客户端不可指定;
|
|
// - 总结:后台默认模型(IsDefault),作用域开关 LLMClientChoice 打开时客户端可在
|
|
// ClientVisible 且 Enabled 的服务里指定(starttask/summary 的 summary_svc_id)。
|
|
//
|
|
// 编排未配置即报错(fail-closed),提示去后台「会议记录服务」页配置,不回退任何旧模式。
|
|
// 快照整体替换(RWMutex),重载途径:启动加载 + NATS ConfigChanged(echomeet_orch/third_svc) + 10 分钟 cron 兜底。
|
|
type providersComp struct {
|
|
cbase.ModuleCompBase
|
|
module *Echomeet
|
|
|
|
// 部署身份(env 注入,与 api 服务 LoadOrSeedServiceConfig 同源)。
|
|
appName string
|
|
regionCode string // APP_REGION 字符串码(app_registry 用)
|
|
region int32 // pb.Region 数值(编排表用)
|
|
encKey string // FIELD_ENCRYPT_KEY,服务池加密字段解密
|
|
|
|
mu sync.RWMutex
|
|
snap *orchSnapshot
|
|
transcribers map[string]Transcriber // key=svc_id
|
|
translators map[string]Translator
|
|
summarizers map[string]Summarizer
|
|
}
|
|
|
|
// 任务状态常量,对齐底层 sys 包
|
|
const (
|
|
StatusRunning = "RUNNING"
|
|
StatusSuccess = "SUCCESS"
|
|
StatusFailed = "FAILED"
|
|
)
|
|
|
|
// SubmitRequest 转写提交请求
|
|
type SubmitRequest struct {
|
|
Uid string
|
|
AudioURL string
|
|
Language string // BCP-47
|
|
EnableSpeaker bool
|
|
Seconds int32
|
|
SizeBytes int64
|
|
CallbackURL string
|
|
CallbackData string
|
|
}
|
|
|
|
// SubmitResult 转写提交结果。Done=true 时同步完成(如字节短音频 flash),直接取 Contexts;
|
|
// 否则需要后续调用 Query 拉取异步结果。
|
|
type SubmitResult struct {
|
|
Done bool
|
|
TaskID string
|
|
LogID string
|
|
Contexts []*pb.ContextStruct
|
|
}
|
|
|
|
// QueryResult 转写查询结果
|
|
type QueryResult struct {
|
|
Status string
|
|
Contexts []*pb.ContextStruct
|
|
}
|
|
|
|
// ChatMessage 对齐 sys/doubao / sys/google/gemini 的统一消息结构。
|
|
// Images 为可选的图片 url 列表(多模态总结用)。
|
|
type ChatMessage struct {
|
|
Role string
|
|
Content string
|
|
Images []string
|
|
}
|
|
|
|
// Transcriber 转写 provider 接口
|
|
type Transcriber interface {
|
|
Submit(ctx context.Context, req SubmitRequest) (*SubmitResult, error)
|
|
Query(ctx context.Context, taskID, logID string) (*QueryResult, error)
|
|
}
|
|
|
|
// Translator 翻译 provider 接口
|
|
type Translator interface {
|
|
Translate(ctx context.Context, from, to string, texts []string) ([]string, error)
|
|
}
|
|
|
|
// Summarizer AI 总结 provider 接口
|
|
type Summarizer interface {
|
|
Chat(ctx context.Context, messages []ChatMessage) (string, error)
|
|
}
|
|
|
|
// ==================== 组件生命周期 ====================
|
|
|
|
func (this *providersComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, options core.IModuleOptions) (err error) {
|
|
this.ModuleCompBase.Init(service, module, comp, options)
|
|
this.module = module.(*Echomeet)
|
|
// 部署身份由 .env 注入(APP_NAME/APP_REGION/FIELD_ENCRYPT_KEY),与 api 服务同源。
|
|
this.appName = os.Getenv("APP_NAME")
|
|
this.regionCode = os.Getenv("APP_REGION")
|
|
this.region = comm.RegionFromCode(this.regionCode)
|
|
this.encKey = os.Getenv("FIELD_ENCRYPT_KEY")
|
|
this.snap = &orchSnapshot{Services: map[string]*resolvedSvc{}}
|
|
this.transcribers = map[string]Transcriber{}
|
|
this.translators = map[string]Translator{}
|
|
this.summarizers = map[string]Summarizer{}
|
|
return
|
|
}
|
|
|
|
func (this *providersComp) Start() (err error) {
|
|
if err = this.ModuleCompBase.Start(); err != nil {
|
|
return
|
|
}
|
|
go this.Reload() // 异步首载(共享库在海外,别阻塞启动)
|
|
this.subscribeConfigChanged()
|
|
cron.AddFunc("0 */10 * * * ?", func() { this.Reload() }) // 定时兜底,广播丢失也能收敛
|
|
this.module.Infof("echomeet 编排组件启动 app=%s region=%s(%d)", this.appName, this.regionCode, this.region)
|
|
return
|
|
}
|
|
|
|
// subscribeConfigChanged 订阅 console 配置变更广播:编排本身或服务池(凭据/区域覆盖)变了都重载。
|
|
func (this *providersComp) subscribeConfigChanged() {
|
|
conn := natssys.Conn()
|
|
if conn == nil {
|
|
this.module.Warnf("echomeet 编排: NATS 未就绪,跳过订阅(cron 定时兜底)")
|
|
return
|
|
}
|
|
_, err := conn.Subscribe(comm.Nats_ConfigChangedSubject, func(msg *gonats.Msg) {
|
|
var ev comm.ConfigChangedEvent
|
|
if err := json.Unmarshal(msg.Data, &ev); err != nil {
|
|
return
|
|
}
|
|
switch ev.Kind {
|
|
case comm.ConfigKindEchomeetOrch, comm.ConfigKindThirdSvc:
|
|
this.module.Infof("echomeet 编排: 收到配置变更(kind=%s action=%s),重载", ev.Kind, ev.Action)
|
|
this.Reload()
|
|
}
|
|
})
|
|
if err != nil {
|
|
this.module.Warnf("echomeet 编排: 订阅配置变更失败: %v", err)
|
|
}
|
|
}
|
|
|
|
// Reload 全量重载编排快照并重建 provider 实例。失败保留旧快照(好过清空导致全部会议失败)。
|
|
func (this *providersComp) Reload() {
|
|
snap, err := loadOrchSnapshot(this.appName, this.region, this.regionCode, this.encKey)
|
|
if err != nil {
|
|
this.module.Errorf("echomeet 编排: 加载失败,保留现有快照: %v", err)
|
|
return
|
|
}
|
|
transcribers := map[string]Transcriber{}
|
|
translators := map[string]Translator{}
|
|
summarizers := map[string]Summarizer{}
|
|
build := func(list []*comm.EchoOrchestration, kind int32) {
|
|
for _, r := range list {
|
|
svc := snap.Services[r.SvcId]
|
|
switch kind {
|
|
case comm.EchoKindASR:
|
|
if _, ok := transcribers[r.SvcId]; ok {
|
|
continue
|
|
}
|
|
t, err := buildTranscriber(svc)
|
|
if err != nil {
|
|
this.module.Errorf("echomeet 编排: 构建识别服务 %s(%s) 失败已跳过: %v", svc.SvcId, svc.Provider, err)
|
|
continue
|
|
}
|
|
transcribers[r.SvcId] = t
|
|
case comm.EchoKindMT:
|
|
if _, ok := translators[r.SvcId]; ok {
|
|
continue
|
|
}
|
|
t, err := buildTranslator(svc)
|
|
if err != nil {
|
|
this.module.Errorf("echomeet 编排: 构建翻译服务 %s(%s) 失败已跳过: %v", svc.SvcId, svc.Provider, err)
|
|
continue
|
|
}
|
|
translators[r.SvcId] = t
|
|
case comm.EchoKindLLM:
|
|
if _, ok := summarizers[r.SvcId]; ok {
|
|
continue
|
|
}
|
|
s, err := buildSummarizer(svc)
|
|
if err != nil {
|
|
this.module.Errorf("echomeet 编排: 构建总结服务 %s(%s) 失败已跳过: %v", svc.SvcId, svc.Provider, err)
|
|
continue
|
|
}
|
|
summarizers[r.SvcId] = s
|
|
}
|
|
}
|
|
}
|
|
build(snap.ASR, comm.EchoKindASR)
|
|
build(snap.MT, comm.EchoKindMT)
|
|
build(snap.LLM, comm.EchoKindLLM)
|
|
|
|
this.mu.Lock()
|
|
this.snap = snap
|
|
this.transcribers = transcribers
|
|
this.translators = translators
|
|
this.summarizers = summarizers
|
|
this.mu.Unlock()
|
|
this.module.Infof("echomeet 编排: 重载完成 识别=%d 翻译=%d 总结=%d (llm_client_choice=%v)",
|
|
len(snap.ASR), len(snap.MT), len(snap.LLM), snap.Setting.LLMClientChoice)
|
|
}
|
|
|
|
// errOrchNotConfigured 统一的未配置提示(fail-closed,不回退旧 yaml 模式)。
|
|
func errOrchNotConfigured(what string) error {
|
|
return fmt.Errorf("会议记录%s服务未配置或不可用,请在后台「会议记录服务」页配置", what)
|
|
}
|
|
|
|
// ==================== 选路 ====================
|
|
|
|
// PickASR 识别选路:按后台 Priority 升序,跳过不支持源语言的服务(languages 字段过滤),
|
|
// 选第一个可用的。返回 (svc_id, 实例, 回调地址)。
|
|
func (this *providersComp) PickASR(language string) (svcId string, t Transcriber, callbackURL string, err error) {
|
|
this.mu.RLock()
|
|
defer this.mu.RUnlock()
|
|
for _, r := range this.snap.ASR {
|
|
svc := this.snap.Services[r.SvcId]
|
|
inst := this.transcribers[r.SvcId]
|
|
if svc == nil || inst == nil {
|
|
continue
|
|
}
|
|
if !svcSupportsLanguage(svc.Languages, language) {
|
|
continue
|
|
}
|
|
return r.SvcId, inst, svc.CallbackURL, nil
|
|
}
|
|
return "", nil, "", errOrchNotConfigured("识别(支持语言 " + language + " 的)")
|
|
}
|
|
|
|
// PickMT 翻译选路:按后台 Priority 升序(服务配了 languages 则过滤,未配不限)。
|
|
func (this *providersComp) PickMT(language string) (svcId string, t Translator, err error) {
|
|
this.mu.RLock()
|
|
defer this.mu.RUnlock()
|
|
for _, r := range this.snap.MT {
|
|
svc := this.snap.Services[r.SvcId]
|
|
inst := this.translators[r.SvcId]
|
|
if svc == nil || inst == nil {
|
|
continue
|
|
}
|
|
if !svcSupportsLanguage(svc.Languages, language) {
|
|
continue
|
|
}
|
|
return r.SvcId, inst, nil
|
|
}
|
|
return "", nil, errOrchNotConfigured("翻译")
|
|
}
|
|
|
|
// PickLLM 总结选路:clientSvcId 非空且后台开关允许客户端指定时,在 ClientVisible 且已构建
|
|
// 的服务里精确匹配;否则默认模型(IsDefault);再否则按 Priority 第一个可用的。
|
|
func (this *providersComp) PickLLM(clientSvcId string) (svcId string, s Summarizer, err error) {
|
|
this.mu.RLock()
|
|
defer this.mu.RUnlock()
|
|
if clientSvcId != "" && this.snap.Setting.LLMClientChoice {
|
|
for _, r := range this.snap.LLM {
|
|
if r.SvcId == clientSvcId && r.ClientVisible && this.summarizers[r.SvcId] != nil {
|
|
return r.SvcId, this.summarizers[r.SvcId], nil
|
|
}
|
|
}
|
|
this.module.Warnf("echomeet 编排: 客户端指定的总结服务 %s 不在可选列表,回退默认", clientSvcId)
|
|
}
|
|
var first *comm.EchoOrchestration
|
|
for _, r := range this.snap.LLM {
|
|
if this.summarizers[r.SvcId] == nil {
|
|
continue
|
|
}
|
|
if r.IsDefault {
|
|
return r.SvcId, this.summarizers[r.SvcId], nil
|
|
}
|
|
if first == nil {
|
|
first = r
|
|
}
|
|
}
|
|
if first != nil {
|
|
return first.SvcId, this.summarizers[first.SvcId], nil
|
|
}
|
|
return "", nil, errOrchNotConfigured("总结大模型")
|
|
}
|
|
|
|
// ==================== 按 svc_id 取实例(record 存量路由:轮询/翻译/总结) ====================
|
|
|
|
func (this *providersComp) GetTranscriber(svcId string) (Transcriber, error) {
|
|
this.mu.RLock()
|
|
defer this.mu.RUnlock()
|
|
if t, ok := this.transcribers[svcId]; ok && t != nil {
|
|
return t, nil
|
|
}
|
|
return nil, fmt.Errorf("识别服务 %s 已不在编排中(可能被后台移除)", svcId)
|
|
}
|
|
|
|
func (this *providersComp) GetTranslator(svcId string) (Translator, error) {
|
|
this.mu.RLock()
|
|
defer this.mu.RUnlock()
|
|
if t, ok := this.translators[svcId]; ok && t != nil {
|
|
return t, nil
|
|
}
|
|
return nil, fmt.Errorf("翻译服务 %s 已不在编排中(可能被后台移除)", svcId)
|
|
}
|
|
|
|
func (this *providersComp) GetSummarizer(svcId string) (Summarizer, error) {
|
|
this.mu.RLock()
|
|
defer this.mu.RUnlock()
|
|
if s, ok := this.summarizers[svcId]; ok && s != nil {
|
|
return s, nil
|
|
}
|
|
return nil, fmt.Errorf("总结服务 %s 已不在编排中(可能被后台移除)", svcId)
|
|
}
|
|
|
|
// ASRProvider 返回某识别服务的 provider 标识(bytedance/alibaba/google/azure),
|
|
// starttask 用它判断是否走字节短音频专用限流队列。
|
|
func (this *providersComp) ASRProvider(svcId string) string {
|
|
this.mu.RLock()
|
|
defer this.mu.RUnlock()
|
|
if svc := this.snap.Services[svcId]; svc != nil {
|
|
return strings.ToLower(svc.Provider)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ASRCallbackURL 取某识别服务拼好的回调地址(tasks 提交异步转写时用)。
|
|
func (this *providersComp) ASRCallbackURL(svcId string) string {
|
|
this.mu.RLock()
|
|
defer this.mu.RUnlock()
|
|
if svc := this.snap.Services[svcId]; svc != nil {
|
|
return svc.CallbackURL
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// SummaryServices 客户端可选总结服务列表(echomeet_getsummaryservices 接口用)。
|
|
// 开关关闭时仅返回默认项(客户端只读展示)。
|
|
func (this *providersComp) SummaryServices() (clientChoice bool, items []*pb.EchomeetSummaryServiceItem) {
|
|
this.mu.RLock()
|
|
defer this.mu.RUnlock()
|
|
clientChoice = this.snap.Setting.LLMClientChoice
|
|
for _, r := range this.snap.LLM {
|
|
svc := this.snap.Services[r.SvcId]
|
|
if svc == nil || this.summarizers[r.SvcId] == nil {
|
|
continue
|
|
}
|
|
if !r.IsDefault && (!clientChoice || !r.ClientVisible) {
|
|
continue
|
|
}
|
|
items = append(items, &pb.EchomeetSummaryServiceItem{
|
|
SvcId: r.SvcId,
|
|
Name: svc.Name,
|
|
IsDefault: r.IsDefault,
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|