- 将文件地址前缀与上传逻辑迁移至 common/oss - 恢复执行改用 utils.WithLock 自动续期锁 - 转写提示词增加单镜头最小时长约束
481 lines
14 KiB
Go
481 lines
14 KiB
Go
package flow
|
||
|
||
import (
|
||
"ai-agent/gateway"
|
||
"ai-agent/workflow/consts/model"
|
||
"ai-agent/workflow/consts/node"
|
||
flowDto "ai-agent/workflow/model/dto/flow"
|
||
"context"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
"unicode/utf8"
|
||
|
||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||
"gitea.redpowerfuture.com/red-future/common/oss"
|
||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/net/ghttp"
|
||
"github.com/gogf/gf/v2/util/gconv"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// 全局等待任务回调的工具
|
||
var (
|
||
asyncMu sync.Mutex
|
||
asyncTasks = make(map[string]chan any)
|
||
)
|
||
|
||
// Wait 阻塞等待回调结果
|
||
// 调用后会一直卡住,直到 Notify 唤醒 或 超时/取消
|
||
func Wait(ctx context.Context, taskId string) (any, error) {
|
||
asyncMu.Lock()
|
||
ch := make(chan any, 1)
|
||
asyncTasks[taskId] = ch
|
||
asyncMu.Unlock()
|
||
|
||
defer close(ch)
|
||
for {
|
||
select {
|
||
case result := <-ch:
|
||
return result, nil
|
||
case <-ctx.Done():
|
||
asyncMu.Lock()
|
||
delete(asyncTasks, taskId)
|
||
asyncMu.Unlock()
|
||
return nil, ctx.Err()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Notify 回调时调用,唤醒等待的任务
|
||
func Notify(taskId string, result any) {
|
||
asyncMu.Lock()
|
||
defer asyncMu.Unlock()
|
||
|
||
ch, exist := asyncTasks[taskId]
|
||
if !exist {
|
||
return
|
||
}
|
||
ch <- result
|
||
delete(asyncTasks, taskId)
|
||
}
|
||
|
||
// ModelCallResultLambda 调用模型并返回输出内容列表,同时回传本次调用的 token/费用(*gateway.ModelCallRes)
|
||
// 与是否推理模型(供 ModelLambda 决定分批结果是否拼接),供调用方(ModelLambda)累计写入节点执行记录
|
||
// token_info,最后由汇总节点聚合到 exec_workflow。
|
||
func ModelCallResultLambda(ctx context.Context, modelId int64, sessionId string, modelRequestParams map[string]any, prompt string, execId int64, nodeId string, segIdx int) ([]map[string]any, *gateway.ModelCallRes, bool, error) {
|
||
modelInfo, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: modelId})
|
||
if err != nil {
|
||
return nil, nil, false, fmt.Errorf("获取模型配置失败: %w", err)
|
||
}
|
||
// model-gateway 对不存在的模型返回 modelManage=null(HTTP 仍 200),零值 struct 里 ResponseType 是 nil 指针,
|
||
// 直接传入 ModelCallResult 会在 *responseType 处 panic,这里提前报业务错误
|
||
if g.IsEmpty(modelInfo.ModelManage.Id) {
|
||
return nil, nil, false, fmt.Errorf("模型配置不存在: modelId=%d", modelId)
|
||
}
|
||
businessParams := make(map[string]any)
|
||
if !g.IsEmpty(prompt) {
|
||
if modelInfo.ModelManage.ModelType != nil && *modelInfo.ModelManage.ModelType == model.TypeVideo {
|
||
businessParams["user_prompt"] = prompt
|
||
} else if modelInfo.ModelManage.ModelType != nil && *modelInfo.ModelManage.ModelType == model.TypeInference {
|
||
businessParams["system_prompt"] = prompt
|
||
}
|
||
}
|
||
// 推理模型:分批调用结果需拼接为单个字段,模型类型仅网关配置携带,此处顺带判断
|
||
isInference := modelInfo.ModelManage.ModelType != nil && *modelInfo.ModelManage.ModelType == model.TypeInference
|
||
// 统一异步入口:提交落库 flow_async_task,崩溃恢复重订阅 msg_topic 拿回结果(同步模型直接调用,不落库)
|
||
responseParams, err := AsyncModelCallWithRecovery(ctx, execId, nodeId, segIdx, modelId, modelInfo.ModelManage.ResponseType, sessionId, modelRequestParams, businessParams)
|
||
if err != nil {
|
||
return nil, nil, false, err
|
||
}
|
||
if g.IsEmpty(responseParams) {
|
||
return nil, nil, false, fmt.Errorf("生成内容为空")
|
||
}
|
||
outputRes := make([]map[string]any, 0)
|
||
for key, val := range responseParams.Content {
|
||
outputRes = append(outputRes, map[string]any{
|
||
key: val,
|
||
})
|
||
}
|
||
return outputRes, responseParams, isInference, nil
|
||
}
|
||
|
||
func HttpCallResultLambda(ctx context.Context, nodeInput *flowDto.NodeExecutionInput) ([]map[string]any, error) {
|
||
var method, url, responseType, callbackUrl string
|
||
var headers map[string]string
|
||
var body map[string]any
|
||
var responseMapping map[string]any
|
||
|
||
n := new([]node.NodePresetField)
|
||
err := gconv.Structs(nodeInput.Config.OutputConfig, n)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
for _, item := range *n {
|
||
switch item.Field {
|
||
case "method":
|
||
method = gconv.String(item.Value)
|
||
case "url":
|
||
url = gconv.String(item.Value)
|
||
case "headers":
|
||
headers = gconv.MapStrStr(item.Value)
|
||
case "body":
|
||
body = gconv.Map(item.Value)
|
||
case "response":
|
||
// 先剥掉 {type, value/attrs} 包裹层,得到干净的输出结构模板
|
||
responseMapping = gconv.Map(UnwrapSchemaWrapper(gconv.Map(item.Value)))
|
||
case "responseType":
|
||
responseType = gconv.String(item.Value)
|
||
if responseType == "callback" {
|
||
callbackUrl = item.Options[0].Config[0].Value
|
||
}
|
||
}
|
||
}
|
||
|
||
if method == "" {
|
||
return nil, fmt.Errorf("method为空")
|
||
}
|
||
if url == "" {
|
||
return nil, fmt.Errorf("url为空")
|
||
}
|
||
|
||
if headers == nil {
|
||
headers = make(map[string]string)
|
||
if r := g.RequestFromCtx(ctx); r != nil {
|
||
for k, v := range r.Request.Header {
|
||
if len(v) > 0 {
|
||
headers[k] = v[0]
|
||
}
|
||
}
|
||
}
|
||
// 后台恢复续跑无 HTTP 请求时(ctx 携带合成 user、无 token),补充 X-User-Info 供下游内部服务
|
||
// GetUserInfo 识别租户;正常执行 ctx 无 user(仅 request 有 Authorization),不会注入,
|
||
// 避免把内部身份透传给任意外部 URL。与 gateway/model.go requestHeaders 的恢复兜底保持一致
|
||
if headers["X-User-Info"] == "" {
|
||
if u := ctx.Value("user"); u != nil {
|
||
headers["X-User-Info"] = gconv.String(u)
|
||
}
|
||
}
|
||
}
|
||
|
||
g.Log().Debugf(ctx, "httpCallResultLambda: body: %v", body)
|
||
|
||
// 构建请求参数
|
||
ProcessValueSourceRecursive(body, nodeInput.Global)
|
||
// 递归剥掉 {type, value/attrs} 包裹层,只保留 key/value
|
||
wrapper := UnwrapSchemaWrapper(body)
|
||
newBody := gconv.Map(wrapper)
|
||
// body 值若为 MinIO 裸路径(模型网关转存 OSS 后返回,无 http 前缀),
|
||
// 补上前缀供目标 HTTP 服务直接下载文件
|
||
addFilePathPrefix(ctx, url, newBody)
|
||
|
||
// 打印入参
|
||
g.Log().Debugf(ctx, "httpCallResultLambda: newBody: %v", newBody)
|
||
|
||
// 1. 自己生成唯一 taskId(不用前端给)
|
||
taskId := "my_task_" + uuid.New().String() // 自己生成唯一ID
|
||
if responseType == "callback" {
|
||
newBody[callbackUrl] = utils.GetCallbackURL(ctx, "/httpNodeCallback?task_id="+taskId)
|
||
}
|
||
// ====================== 核心改动 ======================
|
||
// 1. 定义一个空map接收原始HTTP返回结果
|
||
var rawHttpResult map[string]any
|
||
// 2. 发送请求(不变)
|
||
if method == "GET" {
|
||
err = commonHttp.Get(ctx, url, headers, &rawHttpResult, newBody)
|
||
} else if method == "POST" {
|
||
err = commonHttp.Post(ctx, url, headers, &rawHttpResult, newBody)
|
||
} else if method == "PUT" {
|
||
err = commonHttp.Put(ctx, url, headers, &rawHttpResult, newBody)
|
||
} else if method == "DELETE" {
|
||
err = commonHttp.Delete(ctx, url, headers, &rawHttpResult, newBody)
|
||
} else {
|
||
return nil, fmt.Errorf("method 不支持")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var e = ""
|
||
|
||
finalResult := make(map[string]any)
|
||
if responseType == "sync" {
|
||
httpResultJson := gconv.String(rawHttpResult)
|
||
// 按 responseMapping 定义的结构,从 http 返回结果中拷贝对应字段
|
||
finalResult = MapResultByTemplate(responseMapping, rawHttpResult)
|
||
e = httpResultJson
|
||
}
|
||
if responseType == "callback" {
|
||
var waitResult any
|
||
waitResult, err = Wait(ctx, taskId)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
request, ok := waitResult.(*ghttp.Request)
|
||
if !ok {
|
||
return nil, fmt.Errorf("入参类型错误")
|
||
}
|
||
|
||
bodyStr := request.GetBodyString()
|
||
// 按 responseMapping 定义的结构,从回调结果中拷贝对应字段
|
||
finalResult = MapResultByTemplate(responseMapping, gconv.Map(bodyStr))
|
||
e = bodyStr
|
||
}
|
||
if responseType == "pull" {
|
||
return nil, fmt.Errorf("pull 暂不支持")
|
||
}
|
||
|
||
if g.IsEmpty(finalResult) {
|
||
return nil, fmt.Errorf("http请求异常,返回结果为空:%v", e)
|
||
}
|
||
|
||
outputRes := make([]map[string]any, 0)
|
||
for i, item := range finalResult {
|
||
if nodeInput.Config.IsSaveFile {
|
||
outputRes = append(outputRes, map[string]any{
|
||
fmt.Sprintf("http_file_url:%v", i): item,
|
||
})
|
||
}
|
||
outputRes = append(outputRes, map[string]any{
|
||
fmt.Sprintf("%v", i): item,
|
||
})
|
||
}
|
||
|
||
return outputRes, nil
|
||
}
|
||
|
||
// addFilePathPrefix 递归把 body 中的 MinIO 裸路径(模型网关转存 OSS 后返回,无 http 前缀)补上文件前缀,
|
||
// 供目标 HTTP 服务直接下载文件;已是完整 URL 的值保持不变
|
||
func addFilePathPrefix(ctx context.Context, url string, body map[string]any) {
|
||
prefix, err := oss.GetFileAddressPrefix(ctx)
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "获取文件前缀失败,保持原路径: %v", err)
|
||
return
|
||
}
|
||
for k, v := range body {
|
||
if k == "templates" && g.IsEmpty(v) {
|
||
delete(body, k)
|
||
continue
|
||
}
|
||
body[k] = prependFilePathPrefix(prefix, v)
|
||
}
|
||
// template/template 模板接口要求 video_urls 为数组:标量值包装为单元素数组
|
||
if strings.Contains(url, "template/template") {
|
||
if v, ok := body["video_urls"]; ok {
|
||
body["video_urls"] = toVideoURLsArray(v)
|
||
}
|
||
if v, ok := body["subtitles"]; ok {
|
||
a := new([]flowDto.Sentence)
|
||
err = gconv.Structs(v, a)
|
||
v, err = BuildSubtitles(a)
|
||
body["subtitles"] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
// toVideoURLsArray 把标量 video_urls 包装为数组;已是数组/切片则原样保留
|
||
func toVideoURLsArray(v any) any {
|
||
switch val := v.(type) {
|
||
case string:
|
||
if val == "" {
|
||
return []string{}
|
||
}
|
||
return []string{val}
|
||
case []string, []any:
|
||
return val
|
||
default:
|
||
return v
|
||
}
|
||
}
|
||
|
||
// prependFilePathPrefix 对单个值加前缀,递归处理嵌套 map/切片
|
||
func prependFilePathPrefix(prefix string, v any) any {
|
||
switch val := v.(type) {
|
||
case string:
|
||
if oss.IsOSSPath(val) {
|
||
return prefix + val
|
||
}
|
||
return val
|
||
case map[string]any:
|
||
for k, item := range val {
|
||
val[k] = prependFilePathPrefix(prefix, item)
|
||
}
|
||
return val
|
||
case []any:
|
||
for i, item := range val {
|
||
val[i] = prependFilePathPrefix(prefix, item)
|
||
}
|
||
return val
|
||
case []map[string]any:
|
||
for _, m := range val {
|
||
for k, item := range m {
|
||
m[k] = prependFilePathPrefix(prefix, item)
|
||
}
|
||
}
|
||
return val
|
||
default:
|
||
return val
|
||
}
|
||
}
|
||
|
||
// punctRe 切分/剥离用的中文标点(含顿号、)
|
||
var punctRe = regexp.MustCompile(`[,。;!?、]`)
|
||
|
||
// BuildSubtitles 核心工具:单个sentence生成多条subtitle
|
||
func BuildSubtitles(sents *[]flowDto.Sentence) ([]flowDto.Subtitle, error) {
|
||
var subtitles []flowDto.Subtitle
|
||
|
||
for _, sent := range *sents {
|
||
// 1. 先按标点把文本拆成多个片段
|
||
segList := splitTextByPunct(sent.Text)
|
||
if len(segList) == 0 {
|
||
continue
|
||
}
|
||
|
||
// 去标点后得到纯净片段(纯空白/纯标点片段跳过)
|
||
var cleans []string
|
||
for _, seg := range segList {
|
||
c := strings.TrimSpace(cleanPunct(seg))
|
||
if c != "" {
|
||
cleans = append(cleans, c)
|
||
}
|
||
}
|
||
if len(cleans) == 0 || len(sent.Words) == 0 {
|
||
continue
|
||
}
|
||
|
||
// 2. 词级文本与句子文本一致时,按词精确对齐取首尾词时间(最准)
|
||
if spans, ok := alignAllSegments(sent.Words, cleans); ok {
|
||
for i, span := range spans {
|
||
subtitles = append(subtitles, flowDto.Subtitle{
|
||
Start: sent.Words[span[0]].StartTime,
|
||
End: sent.Words[span[1]].EndTime,
|
||
Text: cleans[i],
|
||
})
|
||
}
|
||
continue
|
||
}
|
||
|
||
// 3. ASR 词级转写与句子文本不一致时(如 血→谑、数字写法不一),
|
||
// 整句回退为按片段字符占比分配时间,避免整句被吞成一条字幕
|
||
segWords := allocWordsByProportion(sent.Words, cleans)
|
||
for i, ws := range segWords {
|
||
if len(ws) == 0 {
|
||
continue
|
||
}
|
||
subtitles = append(subtitles, flowDto.Subtitle{
|
||
Start: ws[0].StartTime,
|
||
End: ws[len(ws)-1].EndTime,
|
||
Text: cleans[i],
|
||
})
|
||
}
|
||
}
|
||
|
||
return subtitles, nil
|
||
}
|
||
|
||
// splitTextByPunct 按中文标点分割句子,同时保留标点在分段内
|
||
// 例如:"这个叫高血压调理方,注意是根源调理不是临时缓解,"
|
||
// 会变成:["这个叫高血压调理方,", "注意是根源调理不是临时缓解,"]
|
||
func splitTextByPunct(raw string) []string {
|
||
// 匹配中文标点并保留在文本中,按标点位置切分
|
||
indexes := punctRe.FindAllStringIndex(raw, -1)
|
||
if len(indexes) == 0 {
|
||
return []string{raw}
|
||
}
|
||
|
||
var res []string
|
||
prev := 0
|
||
for _, idx := range indexes {
|
||
end := idx[1] // 标点的结束位置
|
||
seg := raw[prev:end]
|
||
res = append(res, seg)
|
||
prev = end
|
||
}
|
||
// 处理最后一段没有标点的文本
|
||
if prev < len(raw) {
|
||
res = append(res, raw[prev:])
|
||
}
|
||
return res
|
||
}
|
||
|
||
// cleanPunct 去掉中文标点,得到纯净文本
|
||
func cleanPunct(raw string) string {
|
||
return punctRe.ReplaceAllString(raw, "")
|
||
}
|
||
|
||
// alignAllSegments 按顺序把各纯净片段与词级文本逐字符对齐(允许个别字符不一致)。
|
||
// 全部片段对齐成功且词被完整覆盖时返回各片段对应的词区间,否则 ok=false,
|
||
// 由调用方回退到时间占比分配。
|
||
func alignAllSegments(words []flowDto.Word, cleans []string) ([][2]int, bool) {
|
||
spans := make([][2]int, len(cleans))
|
||
wordIdx := 0
|
||
for i, seg := range cleans {
|
||
start := wordIdx
|
||
segRunes := []rune(seg)
|
||
s := 0
|
||
for wordIdx < len(words) && s < len(segRunes) {
|
||
for _, r := range []rune(words[wordIdx].Word) {
|
||
if s < len(segRunes) && r == segRunes[s] {
|
||
s++
|
||
}
|
||
}
|
||
wordIdx++
|
||
}
|
||
// 片段文本没被完整匹配,或该片段没吃到任何词 → 无法精确对齐
|
||
if s < len(segRunes) || start == wordIdx {
|
||
return nil, false
|
||
}
|
||
spans[i] = [2]int{start, wordIdx - 1}
|
||
}
|
||
// 有剩余词未被任何片段覆盖,说明对齐失败,避免吞掉剩余时间
|
||
if wordIdx < len(words) {
|
||
return nil, false
|
||
}
|
||
return spans, true
|
||
}
|
||
|
||
// allocWordsByProportion 按纯净片段字符占比把整句时间区间切成段,再按时间中点把
|
||
// 每个 word 归属到所属片段(对词级转写与句子文本不一致的情况兜底)。
|
||
func allocWordsByProportion(words []flowDto.Word, cleans []string) [][]flowDto.Word {
|
||
runes := make([]int, len(cleans))
|
||
totalChars := 0
|
||
for i, c := range cleans {
|
||
runes[i] = utf8.RuneCountInString(c)
|
||
totalChars += runes[i]
|
||
}
|
||
|
||
sentStart := words[0].StartTime
|
||
sentEnd := words[len(words)-1].EndTime
|
||
duration := sentEnd - sentStart
|
||
if duration < 0 {
|
||
duration = 0
|
||
}
|
||
|
||
bounds := make([]float64, len(cleans)+1)
|
||
bounds[0] = sentStart
|
||
accum := 0.0
|
||
for i := range cleans {
|
||
if totalChars > 0 {
|
||
accum += float64(runes[i]) / float64(totalChars)
|
||
}
|
||
bounds[i+1] = sentStart + accum*duration
|
||
}
|
||
|
||
segWords := make([][]flowDto.Word, len(cleans))
|
||
for _, w := range words {
|
||
mid := (w.StartTime + w.EndTime) / 2
|
||
idx := 0
|
||
for b := 0; b < len(bounds)-1; b++ {
|
||
if mid >= bounds[b+1] {
|
||
idx = b + 1
|
||
}
|
||
}
|
||
segWords[idx] = append(segWords[idx], w)
|
||
}
|
||
return segWords
|
||
}
|