feat: 支持工作流断点续跑并拆分错误信息存储
- 新增同会话+同工作流最近执行失败且参数一致时断点续跑逻辑 - exec_workflow/exec_chat 新增 error 字段存储原始错误,error_message 仅存友好提示 - 新增 UpdateExecChatReq 与 exec_chat_dao Update 方法 - 新增 GetLatestBySessionAndFlow 查询最近执行记录 - 修正 ListDates 分组与排序 SQL 表达式 - 新增 pipeline 配置结构,删除旧设计文档
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
package video
|
||||
package media
|
||||
|
||||
import (
|
||||
"ai-agent/workflow/service/flow/processor"
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
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/util/gconv"
|
||||
)
|
||||
@@ -41,6 +42,9 @@ type mergeSubmitRes struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
|
||||
// ProcessorName 处理器注册名,供模型节点前置工具分发按名判定入参形态(扁平请求体)。
|
||||
const ProcessorName = "concat_videos"
|
||||
|
||||
func init() {
|
||||
processor.Register(ConcatVideosProcessor())
|
||||
}
|
||||
@@ -48,11 +52,12 @@ func init() {
|
||||
// ConcatVideosProcessor 合并视频
|
||||
func ConcatVideosProcessor() *processor.Processor {
|
||||
return &processor.Processor{
|
||||
Name: "concat_videos",
|
||||
Name: ProcessorName,
|
||||
Description: "合并视频",
|
||||
IsShow: false,
|
||||
Func: func(ctx context.Context, args map[string]any) (any, error) {
|
||||
outputRes := parseOutputList(args)
|
||||
segments, err := collectSegmentResults(outputRes)
|
||||
segments, err := collectSegmentResults(ctx, outputRes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -77,10 +82,16 @@ func ConcatVideosProcessor() *processor.Processor {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 合并结果沿用输入视频的 key 返回,保持 key 不变;
|
||||
// 否则下游按原 key 引用(值来源/保存文件映射)会失配
|
||||
retKey := "fileURL"
|
||||
if len(outputRes) > 0 {
|
||||
if k := findVideoKey(outputRes[0]); k != "" {
|
||||
retKey = k
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"video_url": merged.FileURL,
|
||||
"duration_str": merged.DurationStr,
|
||||
"task_id": merged.TaskID,
|
||||
retKey: merged.FileURL,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
@@ -217,7 +228,7 @@ type segmentResult struct {
|
||||
// collectSegmentResults 把模型节点/串行工具的产出([]map[string]any)收敛为有序的分段结果列表。
|
||||
// 兼容两种形状:串行工具产出的 {segment_index, video_url, duration};
|
||||
// 并行模型调用产出的 {<url字段>: url}(按列表顺序对应各段)。
|
||||
func collectSegmentResults(outputRes []map[string]any) ([]segmentResult, error) {
|
||||
func collectSegmentResults(ctx context.Context, outputRes []map[string]any) ([]segmentResult, error) {
|
||||
if len(outputRes) == 0 {
|
||||
return nil, fmt.Errorf("没有可合并的分段视频")
|
||||
}
|
||||
@@ -226,7 +237,7 @@ func collectSegmentResults(outputRes []map[string]any) ([]segmentResult, error)
|
||||
seg := segmentResult{
|
||||
SegmentIndex: i,
|
||||
Duration: gconv.Int(m["duration"]),
|
||||
VideoURL: findVideoURL(m),
|
||||
VideoURL: findVideoURL(ctx, m),
|
||||
}
|
||||
if idx := gconv.Int(m["segment_index"]); len(outputRes) > 1 && idx > 0 {
|
||||
seg.SegmentIndex = idx
|
||||
@@ -239,23 +250,54 @@ func collectSegmentResults(outputRes []map[string]any) ([]segmentResult, error)
|
||||
return segs, nil
|
||||
}
|
||||
|
||||
// findVideoURL 从模型返回参数中提取视频 URL:优先命中常见键,再兜底任意含 url 的 http 字段。
|
||||
func findVideoURL(params map[string]any) string {
|
||||
// findVideoURL 从模型返回参数中提取视频 URL:优先命中常见键,再兼容扁平点号键(content.attrs.video_url 等)任意含 url 的字段。
|
||||
func findVideoURL(ctx context.Context, params map[string]any) string {
|
||||
key := findVideoKey(params)
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
return normalizeVideoURL(ctx, gconv.String(params[key]))
|
||||
}
|
||||
|
||||
// findVideoKey 返回视频 URL 所在字段的 key(命中规则与 findVideoURL 一致),
|
||||
// 供视频合并后以原 key 返回结果,避免下游按原 key 引用(值来源/保存文件映射)失配。
|
||||
func findVideoKey(params map[string]any) string {
|
||||
if params == nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"video_url", "video_oss_url", "http_file_url", "file_url", "url"} {
|
||||
if v := gconv.String(params[key]); v != "" {
|
||||
return v
|
||||
if gconv.String(params[key]) != "" {
|
||||
return key
|
||||
}
|
||||
}
|
||||
// 兼容扁平点号键(如 content.attrs.video_url):优先命中含 video 的键,再兜底任意含 url 的键
|
||||
var fallback string
|
||||
for k, v := range params {
|
||||
if !strings.Contains(strings.ToLower(k), "url") {
|
||||
continue
|
||||
}
|
||||
if s := gconv.String(v); s != "" && strings.HasPrefix(s, "http") {
|
||||
return s
|
||||
if gconv.String(v) == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.ToLower(k), "video") {
|
||||
return k
|
||||
}
|
||||
if fallback == "" {
|
||||
fallback = k
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return fallback
|
||||
}
|
||||
|
||||
// normalizeVideoURL 统一视频地址:已是完整 http(s) 链接原样返回;相对路径(MinIO 对象路径)补上文件前缀,供 media 服务下载
|
||||
func normalizeVideoURL(ctx context.Context, url string) string {
|
||||
if url == "" || strings.HasPrefix(url, "http") {
|
||||
return url
|
||||
}
|
||||
prefix, err := utils.GetFileAddressPrefix(ctx)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "获取文件前缀失败,视频地址保持相对路径: %s err=%v", url, err)
|
||||
return url
|
||||
}
|
||||
return prefix + url
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// Package split_batch 工作流前置处理器:按各字段 constraint.uploadTotalMaxCount 拆分模型请求参数。
|
||||
// Package split_batch 工作流前置处理器:按默认上限(uploadTotalMaxCount 默认 15)拆分模型请求参数为多批。
|
||||
// 入参为已构建好的扁平模型请求体(BuildModelRequestBody 输出,key 为点分路径,value 已解析填充),
|
||||
// 集合字段按上限分批、元素对象取 url 为值;返回结构同入参(扁平 map 数组),未超量返回单份。
|
||||
// 处理器实现自包含(算法随处理器走,不依赖业务包),通过 init 注册进 processor 注册表。
|
||||
package split_batch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
@@ -12,16 +15,23 @@ import (
|
||||
"ai-agent/workflow/service/flow/processor"
|
||||
)
|
||||
|
||||
// ProcessorName 处理器注册名,供模型节点前置工具分发按名判定入参形态(扁平请求体)。
|
||||
const ProcessorName = "split_batch_model_params"
|
||||
|
||||
// defaultMaxCount 每批最大元素数(constraint.uploadTotalMaxCount 当前默认值,后续动态传递)。
|
||||
const defaultMaxCount = 15
|
||||
|
||||
func init() {
|
||||
processor.Register(SplitBatchModelParamsProcessor())
|
||||
}
|
||||
|
||||
// SplitBatchModelParamsProcessor 将模型请求参数按各字段 constraint.uploadTotalMaxCount 分批的前置处理器。
|
||||
// 入参 args 即模型请求参数本体(需已解析好 valueSource,value 已填充)。
|
||||
// SplitBatchModelParamsProcessor 将模型请求参数按默认上限分批的前置处理器。
|
||||
// 入参 args 即扁平模型请求体(valueSource 已解析、value 已填充)。
|
||||
func SplitBatchModelParamsProcessor() *processor.Processor {
|
||||
return &processor.Processor{
|
||||
Name: "split_batch_model_params",
|
||||
Description: "按 constraint.uploadTotalMaxCount 分批模型请求参数",
|
||||
Name: ProcessorName,
|
||||
Description: "按最大约束构建分批模型请求数据",
|
||||
IsShow: true,
|
||||
Func: func(ctx context.Context, args map[string]any) (any, error) {
|
||||
if args == nil {
|
||||
return nil, fmt.Errorf("缺少模型请求参数")
|
||||
@@ -31,37 +41,41 @@ func SplitBatchModelParamsProcessor() *processor.Processor {
|
||||
}
|
||||
}
|
||||
|
||||
// splitBatchField 描述一个需要按 constraint.uploadTotalMaxCount 分批的字段
|
||||
type splitBatchField struct {
|
||||
path []any // 从参数根节点到该字段 value 的路径(map 用 key,数组用下标)
|
||||
items []any // 该字段 value 拆出的待分批元素列表
|
||||
maxCount int // 每批最大元素数(constraint.uploadTotalMaxCount)
|
||||
// batchField 描述一个需要分批的扁平字段
|
||||
type batchField struct {
|
||||
key string // 扁平点分 key
|
||||
items []any // 分批元素(元素为带 url 字段的对象时已取 url 为值)
|
||||
}
|
||||
|
||||
// SplitBatchModelParams 把模型请求参数拆成多批,供分批请求模型使用。
|
||||
// SplitBatchModelParams 把扁平模型请求体拆成多批,供分批请求模型使用。
|
||||
// 处理流程:
|
||||
// 1. 深拷贝入参,避免污染调用方数据;
|
||||
// 2. 递归解析 valueSource,把引用节点输出的字段值写入对应字段的 value;
|
||||
// 3. 找出所有带 constraint.uploadTotalMaxCount 且 value 为集合(map/slice)的字段,
|
||||
// map 按 key 排序取 value 列表作为元素;总批数 = 各字段 (元素数/上限) 向上取整的最大值;
|
||||
// 4. 每批 = 整份参数深拷贝 + 各分批字段 value 替换为对应切片。
|
||||
// 2. 遍历扁平字段,值为集合(slice/array/map)的按元素数 / defaultMaxCount 向上取整,
|
||||
// map 按 key 排序取 value 列表作为元素;元素为带 url 字段的对象时取 url 为值;
|
||||
// 3. 总批数 = 各字段批数的最大值;每批 = 整份参数深拷贝 + 各分批字段替换为该批切片,
|
||||
// 元素已耗尽的分批字段替换为空切片。
|
||||
//
|
||||
// 未超量时返回单份(value 已被解析填充)参数。调用方可遍历返回值逐个请求模型。
|
||||
// 未超量时返回单份参数。调用方可遍历返回值逐个请求模型。
|
||||
func SplitBatchModelParams(rawParams map[string]any) []map[string]any {
|
||||
params, ok := deepCopyAny(rawParams).(map[string]any)
|
||||
if !ok {
|
||||
params = make(map[string]any)
|
||||
}
|
||||
|
||||
fields := make([]splitBatchField, 0)
|
||||
collectBatchFields(params, nil, &fields)
|
||||
|
||||
fields := make([]batchField, 0)
|
||||
batchCount := 1
|
||||
for _, f := range fields {
|
||||
n := (len(f.items) + f.maxCount - 1) / f.maxCount
|
||||
for key, v := range params {
|
||||
items, has := toItems(v)
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
n := (len(items) + defaultMaxCount - 1) / defaultMaxCount
|
||||
if n > batchCount {
|
||||
batchCount = n
|
||||
}
|
||||
if n > 1 {
|
||||
fields = append(fields, batchField{key: key, items: items})
|
||||
}
|
||||
}
|
||||
if batchCount <= 1 {
|
||||
return []map[string]any{params}
|
||||
@@ -71,67 +85,46 @@ func SplitBatchModelParams(rawParams map[string]any) []map[string]any {
|
||||
for i := 0; i < batchCount; i++ {
|
||||
batch, _ := deepCopyAny(params).(map[string]any)
|
||||
for _, f := range fields {
|
||||
start := i * f.maxCount
|
||||
start := i * defaultMaxCount
|
||||
if start >= len(f.items) {
|
||||
setValueAtPath(batch, f.path, []any{})
|
||||
batch[f.key] = []any{}
|
||||
continue
|
||||
}
|
||||
end := start + f.maxCount
|
||||
end := start + defaultMaxCount
|
||||
if end > len(f.items) {
|
||||
end = len(f.items)
|
||||
}
|
||||
setValueAtPath(batch, f.path, f.items[start:end])
|
||||
batch[f.key] = f.items[start:end]
|
||||
}
|
||||
batches = append(batches, batch)
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
// collectBatchFields 递归收集所有带 constraint.uploadTotalMaxCount 且 value 为集合的分批字段
|
||||
func collectBatchFields(node any, path []any, out *[]splitBatchField) {
|
||||
switch v := node.(type) {
|
||||
case map[string]any:
|
||||
if maxCount, ok := uploadTotalMaxCountOf(v); ok {
|
||||
if items, has := toItems(v["value"]); has {
|
||||
*out = append(*out, splitBatchField{
|
||||
path: append(append([]any{}, path...), "value"),
|
||||
items: items,
|
||||
maxCount: maxCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
for key, child := range v {
|
||||
collectBatchFields(child, append(append([]any{}, path...), key), out)
|
||||
}
|
||||
case []any:
|
||||
for i, child := range v {
|
||||
collectBatchFields(child, append(append([]any{}, path...), i), out)
|
||||
// itemValue 取集合元素作为分批粒度时的值:元素为带 url 字段的对象时取 url("取url为值"),
|
||||
// 其余元素原样保留。
|
||||
func itemValue(e any) any {
|
||||
if m := gconv.Map(e); m != nil {
|
||||
if u, ok := m["url"]; ok && u != nil {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// uploadTotalMaxCountOf 读取 schema 节点 constraint.uploadTotalMaxCount
|
||||
func uploadTotalMaxCountOf(m map[string]any) (int, bool) {
|
||||
c, ok := m["constraint"]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
cm := gconv.Map(c)
|
||||
if cm == nil {
|
||||
return 0, false
|
||||
}
|
||||
n := gconv.Int(cm["uploadTotalMaxCount"])
|
||||
if n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// toItems 把集合 value 转成元素列表:切片原样返回;map 按 key 排序取 value,保证分批顺序稳定
|
||||
// toItems 把集合 value 转成元素列表:切片逐元素转换(对象取 url 为值);map 按 key 排序取 value;
|
||||
// 类型化切片([]string 等)经反射逐元素转换。
|
||||
func toItems(v any) ([]any, bool) {
|
||||
switch val := v.(type) {
|
||||
case []any:
|
||||
return val, len(val) > 0
|
||||
if len(val) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
items := make([]any, 0, len(val))
|
||||
for _, e := range val {
|
||||
items = append(items, itemValue(e))
|
||||
}
|
||||
return items, true
|
||||
case map[string]any:
|
||||
if len(val) == 0 {
|
||||
return nil, false
|
||||
@@ -143,50 +136,26 @@ func toItems(v any) ([]any, bool) {
|
||||
sort.Strings(keys)
|
||||
items := make([]any, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
items = append(items, val[k])
|
||||
items = append(items, itemValue(val[k]))
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) {
|
||||
n := rv.Len()
|
||||
if n == 0 {
|
||||
return nil, false
|
||||
}
|
||||
items := make([]any, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
items = append(items, itemValue(rv.Index(i).Interface()))
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// setValueAtPath 沿 path 逐级导航(map 用 key、数组用下标),在最后一级写入 value
|
||||
func setValueAtPath(root map[string]any, path []any, value any) {
|
||||
if len(path) == 0 {
|
||||
return
|
||||
}
|
||||
var cur any = root
|
||||
for i := 0; i < len(path)-1; i++ {
|
||||
switch step := path[i].(type) {
|
||||
case string:
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cur = m[step]
|
||||
case int:
|
||||
s, ok := cur.([]any)
|
||||
if !ok || step < 0 || step >= len(s) {
|
||||
return
|
||||
}
|
||||
cur = s[step]
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
switch last := path[len(path)-1].(type) {
|
||||
case string:
|
||||
if m, ok := cur.(map[string]any); ok {
|
||||
m[last] = value
|
||||
}
|
||||
case int:
|
||||
if s, ok := cur.([]any); ok && last >= 0 && last < len(s) {
|
||||
s[last] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deepCopyAny 深拷贝 map[string]any / []any 嵌套结构,避免批次之间互相影响
|
||||
// deepCopyAny 深拷贝 map[string]any / []any / 类型化切片嵌套结构,避免批次之间互相影响
|
||||
func deepCopyAny(v any) any {
|
||||
switch val := v.(type) {
|
||||
case map[string]any:
|
||||
@@ -201,7 +170,18 @@ func deepCopyAny(v any) any {
|
||||
res[i] = deepCopyAny(child)
|
||||
}
|
||||
return res
|
||||
default:
|
||||
return val
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) {
|
||||
n := rv.Len()
|
||||
out := reflect.MakeSlice(rv.Type(), n, n)
|
||||
for i := 0; i < n; i++ {
|
||||
d := deepCopyAny(rv.Index(i).Interface())
|
||||
if d != nil {
|
||||
out.Index(i).Set(reflect.ValueOf(d))
|
||||
}
|
||||
}
|
||||
return out.Interface()
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Package split_segment 工作流前置处理器:把聚合了脚本转写各段参数的扁平请求体按段拆成多份,
|
||||
// 供分段模型请求并行执行。
|
||||
//
|
||||
// 脚本转写节点(split_shots_pipeline 产出)的下游模型节点单源引用其字段时,
|
||||
// BuildModelRequestBody 把这些字段的数组值收集进 __segment_fields(逗号分隔的扁平路径),
|
||||
// 处理器按段序把每份参数拆成独立请求体,段与段之间互不影响(深拷贝)。
|
||||
// 处理器自包含(算法随处理器走,不依赖业务包),通过 init 注册进 processor 注册表。
|
||||
package split_segment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"ai-agent/workflow/service/flow/processor"
|
||||
)
|
||||
|
||||
// ProcessorName 处理器注册名,供模型节点前置工具分发按名判定入参形态(扁平请求体 + __segment_fields 标记)。
|
||||
const ProcessorName = "split_segment"
|
||||
|
||||
func init() {
|
||||
processor.Register(SplitSegmentProcessor())
|
||||
}
|
||||
|
||||
// SplitSegmentProcessor 按段拆分模型请求参数的前置处理器。
|
||||
// 入参 args 即扁平模型请求体(BuildModelRequestBody 输出,含 __segment_fields 标记)。
|
||||
func SplitSegmentProcessor() *processor.Processor {
|
||||
return &processor.Processor{
|
||||
Name: ProcessorName,
|
||||
Description: "按段拆分脚本转写聚合的模型请求参数",
|
||||
IsShow: true,
|
||||
Func: func(ctx context.Context, args map[string]any) (any, error) {
|
||||
if args == nil {
|
||||
return nil, fmt.Errorf("缺少模型请求参数")
|
||||
}
|
||||
return SplitSegmentModelParams(args)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// segField 描述一个待拆分的分段字段
|
||||
type segField struct {
|
||||
path string // 扁平点分路径
|
||||
items []any // 按段序排列的元素(元素为 nil 表示该段无此字段)
|
||||
}
|
||||
|
||||
// SplitSegmentModelParams 把扁平模型请求体按段拆成多份,供分段请求模型使用。
|
||||
// 处理流程:
|
||||
// 1. 读取 __segment_fields(逗号分隔的扁平路径),无标记则返回单份参数;
|
||||
// 2. 逐个解析分段字段的数组值,要求非空且各字段长度一致(不一致直接报错,避免按错位拆分);
|
||||
// 3. 按段数深拷贝整份参数,各段覆盖其分段字段为该段元素;元素为 nil(该段无此字段)时删除该键,
|
||||
// 避免把 null 传给模型网关。
|
||||
//
|
||||
// 返回的每份参数仍保留 __segment_fields(由 invokePreTool 统一剥离 __ 前缀内部键)。
|
||||
func SplitSegmentModelParams(rawParams map[string]any) ([]map[string]any, error) {
|
||||
paths := segmentFieldsFromArgs(rawParams)
|
||||
if len(paths) == 0 {
|
||||
return []map[string]any{rawParams}, nil
|
||||
}
|
||||
|
||||
fields := make([]segField, 0, len(paths))
|
||||
segmentCount := 0
|
||||
for _, path := range paths {
|
||||
v, has := rawParams[path]
|
||||
if !has {
|
||||
return nil, fmt.Errorf("分段字段[%s]缺失", path)
|
||||
}
|
||||
items, ok := asItems(v)
|
||||
if !ok || len(items) == 0 {
|
||||
return nil, fmt.Errorf("分段字段[%s]值不是数组或为空", path)
|
||||
}
|
||||
if segmentCount == 0 {
|
||||
segmentCount = len(items)
|
||||
} else if len(items) != segmentCount {
|
||||
return nil, fmt.Errorf("分段字段长度不一致: %s=%d, 期望 %d", path, len(items), segmentCount)
|
||||
}
|
||||
fields = append(fields, segField{path: path, items: items})
|
||||
}
|
||||
|
||||
batches := make([]map[string]any, 0, segmentCount)
|
||||
for i := 0; i < segmentCount; i++ {
|
||||
batch, _ := deepCopyAny(rawParams).(map[string]any)
|
||||
for _, f := range fields {
|
||||
if f.items[i] == nil {
|
||||
delete(batch, f.path)
|
||||
continue
|
||||
}
|
||||
batch[f.path] = f.items[i]
|
||||
}
|
||||
batches = append(batches, batch)
|
||||
}
|
||||
return batches, nil
|
||||
}
|
||||
|
||||
// segmentFieldsFromArgs 解析 __segment_fields 标记为扁平路径列表,空值返回 nil。
|
||||
func segmentFieldsFromArgs(args map[string]any) []string {
|
||||
raw, ok := args["__segment_fields"].(string)
|
||||
if !ok || strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var paths []string
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
paths = append(paths, t)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// asItems 取分段字段值作为元素列表:[]any 直接返回(元素保持原样,nil 段位保留);
|
||||
// 类型化切片([]string 等)经反射逐元素转 any。非切片返回 false。
|
||||
func asItems(v any) ([]any, bool) {
|
||||
if list, ok := v.([]any); ok {
|
||||
return list, true
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) {
|
||||
n := rv.Len()
|
||||
if n == 0 {
|
||||
return nil, true
|
||||
}
|
||||
items := make([]any, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
items = append(items, rv.Index(i).Interface())
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// deepCopyAny 深拷贝 map[string]any / []any / 类型化切片嵌套结构,避免批次之间互相影响
|
||||
func deepCopyAny(v any) any {
|
||||
switch val := v.(type) {
|
||||
case map[string]any:
|
||||
res := make(map[string]any, len(val))
|
||||
for k, child := range val {
|
||||
res[k] = deepCopyAny(child)
|
||||
}
|
||||
return res
|
||||
case []any:
|
||||
res := make([]any, len(val))
|
||||
for i, child := range val {
|
||||
res[i] = deepCopyAny(child)
|
||||
}
|
||||
return res
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) {
|
||||
n := rv.Len()
|
||||
out := reflect.MakeSlice(rv.Type(), n, n)
|
||||
for i := 0; i < n; i++ {
|
||||
d := deepCopyAny(rv.Index(i).Interface())
|
||||
if d != nil {
|
||||
out.Index(i).Set(reflect.ValueOf(d))
|
||||
}
|
||||
}
|
||||
return out.Interface()
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package pipeline
|
||||
|
||||
// Config 统一浮点/阈值配置(设计 §4)。所有阈值唯一出处,阶段函数内不出现硬编码字面量。
|
||||
// 零值字段在 resolveConfig 统一由 DefaultConfig() 补齐。
|
||||
type Config struct {
|
||||
// 时长计算(秒,float 允许更精细的弹性分配)
|
||||
CharPerSecond float64 // 语速(字/秒),默认 4
|
||||
FastCharPerSecond float64 // 感叹/疑问多时的语速,默认 6
|
||||
SlowCharPerSecond float64 // 低落语气时的语速,默认 3
|
||||
MinDurBuffer float64 // 有声镜头时长缓冲(秒),默认 1
|
||||
MinVisualDur float64 // 纯视觉镜头最小时长(秒),默认 1
|
||||
VisualWeight float64 // 纯视觉镜头弹性权重,默认 2.0
|
||||
SpokenWeight float64 // 有声镜头弹性权重,默认 0.5
|
||||
|
||||
// 切分/断句
|
||||
SplitWindow int // 断句搜索窗口(rune),默认 60
|
||||
ShortFragmentDur float64 // 短残片阈值(秒),默认 1;时间线为整数秒,残片 ≤ 该值(通常即 1s)走并入逻辑
|
||||
BoundaryTolerance float64 // 语义切点容差(秒),<=0 时按 max(MaxSegmentDur*0.2, 2) 派生
|
||||
MaxSplitIter int // 残片并入上限重切的迭代上限,默认 10;超限返回 ErrSegmentInfeasible
|
||||
|
||||
// 参考素材与截断
|
||||
MaxRefs int // 单段参考素材上限,默认 5
|
||||
MaxPromptChars int // prompt 截断长度(rune),<=0 不截断
|
||||
MinPromptFloor int // prompt 截断保底长度(rune),默认 50
|
||||
StrictInvariant bool // 时间线不变量校验:true 校验失败返回 ErrTimelineInvariant;false 仅记录告警
|
||||
}
|
||||
|
||||
// DefaultConfig 返回默认配置。BoundaryTolerance 依赖 MaxSegmentDur,由 resolveConfig 派生。
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
CharPerSecond: 4,
|
||||
FastCharPerSecond: 6,
|
||||
SlowCharPerSecond: 3,
|
||||
MinDurBuffer: 1,
|
||||
MinVisualDur: 1,
|
||||
VisualWeight: 2.0,
|
||||
SpokenWeight: 0.5,
|
||||
|
||||
SplitWindow: 60,
|
||||
ShortFragmentDur: 1,
|
||||
BoundaryTolerance: 0, // 派生子:max(MaxSegmentDur*0.2, 2)
|
||||
MaxSplitIter: 10,
|
||||
|
||||
MaxRefs: 5,
|
||||
MaxPromptChars: 0,
|
||||
MinPromptFloor: 50,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveConfig 用默认值补齐 cfg 的零值字段。maxSegmentDur 用于派生 BoundaryTolerance。
|
||||
func resolveConfig(cfg Config, maxSegmentDur int) Config {
|
||||
def := DefaultConfig()
|
||||
if cfg.CharPerSecond <= 0 {
|
||||
cfg.CharPerSecond = def.CharPerSecond
|
||||
}
|
||||
if cfg.FastCharPerSecond <= 0 {
|
||||
cfg.FastCharPerSecond = def.FastCharPerSecond
|
||||
}
|
||||
if cfg.SlowCharPerSecond <= 0 {
|
||||
cfg.SlowCharPerSecond = def.SlowCharPerSecond
|
||||
}
|
||||
if cfg.MinDurBuffer <= 0 {
|
||||
cfg.MinDurBuffer = def.MinDurBuffer
|
||||
}
|
||||
if cfg.MinVisualDur <= 0 {
|
||||
cfg.MinVisualDur = def.MinVisualDur
|
||||
}
|
||||
if cfg.VisualWeight <= 0 {
|
||||
cfg.VisualWeight = def.VisualWeight
|
||||
}
|
||||
if cfg.SpokenWeight <= 0 {
|
||||
cfg.SpokenWeight = def.SpokenWeight
|
||||
}
|
||||
if cfg.SplitWindow <= 0 {
|
||||
cfg.SplitWindow = def.SplitWindow
|
||||
}
|
||||
if cfg.ShortFragmentDur <= 0 {
|
||||
cfg.ShortFragmentDur = def.ShortFragmentDur
|
||||
}
|
||||
if cfg.MaxSplitIter <= 0 {
|
||||
cfg.MaxSplitIter = def.MaxSplitIter
|
||||
}
|
||||
if cfg.MaxRefs <= 0 {
|
||||
cfg.MaxRefs = def.MaxRefs
|
||||
}
|
||||
if cfg.MaxPromptChars <= 0 {
|
||||
cfg.MaxPromptChars = def.MaxPromptChars
|
||||
}
|
||||
if cfg.MinPromptFloor <= 0 {
|
||||
cfg.MinPromptFloor = def.MinPromptFloor
|
||||
}
|
||||
if cfg.BoundaryTolerance <= 0 {
|
||||
tol := float64(maxSegmentDur) * 0.2
|
||||
if tol < 2 {
|
||||
tol = 2
|
||||
}
|
||||
cfg.BoundaryTolerance = tol
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -43,6 +43,7 @@ func SplitShotsPipelineProcessor() *processor.Processor {
|
||||
return &processor.Processor{
|
||||
Name: "split_shots_pipeline",
|
||||
Description: "按 pipeline 时间线算法拆段,产出各段模型请求参数(与 split_shots 并存,灰度用)",
|
||||
IsShow: false,
|
||||
Func: func(ctx context.Context, args map[string]any) (any, error) {
|
||||
input, err := parseSplitShotsInput(args)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type Processor struct {
|
||||
Name string
|
||||
Description string
|
||||
IsShow bool
|
||||
Func func(ctx context.Context, args map[string]any) (any, error)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user