Files
ai-agent/workflow/service/flow/lambda_node_util.go
T
19904408334 f0f8724bd9 fix: 修复并行分支续跑输出丢失及模型空指针
合并续跑时各分支独立的 ConfigMap 副本,避免汇合节点输出丢失;并对模型配置缺失和空 responseType 增加保护,防止空指针崩溃。
2026-08-24 16:04:45 +08:00

468 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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) ([]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=nullHTTP 仍 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
// 异步模型 msgTopic 由 gateway.ModelCallResult 在为空时自动生成(唯一、带业务标识),调用方无需管理
responseParams, err := gateway.ModelCallResult(ctx, 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]
}
}
}
}
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 := utils.GetFileAddressPrefix(ctx)
if err != nil {
g.Log().Warningf(ctx, "获取文件前缀失败,保持原路径: %v", err)
return
}
for k, v := range body {
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 utils.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
}