feat: 支持工作流断点续跑并拆分错误信息存储
- 新增同会话+同工作流最近执行失败且参数一致时断点续跑逻辑 - exec_workflow/exec_chat 新增 error 字段存储原始错误,error_message 仅存友好提示 - 新增 UpdateExecChatReq 与 exec_chat_dao Update 方法 - 新增 GetLatestBySessionAndFlow 查询最近执行记录 - 修正 ListDates 分组与排序 SQL 表达式 - 新增 pipeline 配置结构,删除旧设计文档
This commit is contained in:
@@ -2,10 +2,13 @@ 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"
|
||||
|
||||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||||
@@ -57,20 +60,31 @@ func Notify(taskId string, result any) {
|
||||
delete(asyncTasks, taskId)
|
||||
}
|
||||
|
||||
// ModelCallResultLambda 调用模型并返回输出内容列表,同时回传本次调用的 token/费用(*gateway.ModelCallRes),
|
||||
// 供调用方(ModelLambda)累计写入节点执行记录 token_info,最后由汇总节点聚合到 exec_workflow。
|
||||
func ModelCallResultLambda(ctx context.Context, modelId int64, sessionId string, modelRequestParams map[string]any) ([]map[string]any, *gateway.ModelCallRes, error) {
|
||||
// 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, fmt.Errorf("获取模型配置失败: %w", err)
|
||||
return nil, nil, false, fmt.Errorf("获取模型配置失败: %w", err)
|
||||
}
|
||||
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, nil)
|
||||
responseParams, err := gateway.ModelCallResult(ctx, modelId, modelInfo.ModelManage.ResponseType, sessionId, modelRequestParams, businessParams)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, false, err
|
||||
}
|
||||
if g.IsEmpty(responseParams) {
|
||||
return nil, nil, fmt.Errorf("生成内容为空")
|
||||
return nil, nil, false, fmt.Errorf("生成内容为空")
|
||||
}
|
||||
outputRes := make([]map[string]any, 0)
|
||||
for key, val := range responseParams.Content {
|
||||
@@ -78,7 +92,7 @@ func ModelCallResultLambda(ctx context.Context, modelId int64, sessionId string,
|
||||
key: val,
|
||||
})
|
||||
}
|
||||
return outputRes, responseParams, nil
|
||||
return outputRes, responseParams, isInference, nil
|
||||
}
|
||||
|
||||
func HttpCallResultLambda(ctx context.Context, nodeInput *flowDto.NodeExecutionInput) ([]map[string]any, error) {
|
||||
@@ -137,6 +151,9 @@ func HttpCallResultLambda(ctx context.Context, nodeInput *flowDto.NodeExecutionI
|
||||
// 递归剥掉 {type, value/attrs} 包裹层,只保留 key/value
|
||||
wrapper := UnwrapSchemaWrapper(body)
|
||||
newBody := gconv.Map(wrapper)
|
||||
// body 值若为 MinIO 裸路径(模型网关转存 OSS 后返回,无 http 前缀),
|
||||
// 补上前缀供目标 HTTP 服务直接下载文件
|
||||
addFilePathPrefix(ctx, url, newBody)
|
||||
|
||||
// 1. 自己生成唯一 taskId(不用前端给)
|
||||
taskId := "my_task_" + uuid.New().String() // 自己生成唯一ID
|
||||
@@ -209,3 +226,155 @@ func HttpCallResultLambda(ctx context.Context, nodeInput *flowDto.NodeExecutionI
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
wordIdx := 0
|
||||
allWords := sent.Words
|
||||
// 2. 遍历每个文本片段,匹配对应的Words
|
||||
for _, seg := range segList {
|
||||
// 去除文本片段的标点,方便和Word.Word拼接内容匹配
|
||||
segClean := strings.ReplaceAll(seg, ",", "")
|
||||
segClean = strings.ReplaceAll(segClean, "。", "")
|
||||
segClean = strings.ReplaceAll(segClean, ";", "")
|
||||
segClean = strings.ReplaceAll(segClean, "!", "")
|
||||
segClean = strings.ReplaceAll(segClean, "?", "")
|
||||
|
||||
var collectWords []flowDto.Word
|
||||
var currentText strings.Builder
|
||||
|
||||
// 收集Word直到拼接内容覆盖当前分段
|
||||
for wordIdx < len(allWords) {
|
||||
word := allWords[wordIdx]
|
||||
currentText.WriteString(word.Word)
|
||||
collectWords = append(collectWords, word)
|
||||
wordIdx++
|
||||
|
||||
// 当拼接的文本包含当前分段的纯文本时,停止收集
|
||||
if strings.Contains(currentText.String(), segClean) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(collectWords) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. 生成字幕(时间戳取首尾Word的时间)
|
||||
sub := flowDto.Subtitle{
|
||||
Start: collectWords[0].StartTime,
|
||||
End: collectWords[len(collectWords)-1].EndTime,
|
||||
Text: segClean,
|
||||
}
|
||||
subtitles = append(subtitles, sub)
|
||||
}
|
||||
}
|
||||
|
||||
return subtitles, nil
|
||||
}
|
||||
|
||||
// splitTextByPunct 按中文标点分割句子,同时保留标点在分段内
|
||||
// 例如:"这个叫高血压调理方,注意是根源调理不是临时缓解,"
|
||||
// 会变成:["这个叫高血压调理方,", "注意是根源调理不是临时缓解,"]
|
||||
func splitTextByPunct(raw string) []string {
|
||||
// 匹配中文标点并保留在文本中,按标点位置切分
|
||||
re := regexp.MustCompile(`[,。;!?]`)
|
||||
// 先找到所有标点的位置
|
||||
indexes := re.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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user