This commit is contained in:
Eric 1549169735@qq.com
2026-09-13 20:08:13 +08:00
parent 7cc06976ab
commit da3f16e6db
65 changed files with 3855 additions and 2153 deletions

462
internal/run/stage.go Normal file
View File

@@ -0,0 +1,462 @@
package run
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// stageNodeConfig is the config of a "stage" SOP node. It binds the node to a
// script package stage and optionally to a content supplement form.
type stageNodeConfig struct {
PackageID *uint64 `json:"package_id"`
StageKey string `json:"stage_key"`
FieldKeys []string `json:"field_keys"`
RequiredFieldKeys []string `json:"required_field_keys"`
}
// stageScriptAnswerInput is one script answer inside a batch stage submission.
type stageScriptAnswerInput struct {
ScriptKey string `json:"script_key"`
OptionKeys []string `json:"option_keys"`
Value string `json:"value"`
}
// stageAnswerInput is the shared payload of the stage answer endpoint. Clients
// answer questions locally and submit the whole stage once: script_answers
// carries every answered script, dimension_selects carries the manual
// dimension toggles and answers carries the content supplement form.
type stageAnswerInput struct {
NodeKey string `json:"node_key"`
ScriptKey string `json:"script_key"`
OptionKeys []string `json:"option_keys"`
Value string `json:"value"`
ScriptAnswers []stageScriptAnswerInput `json:"script_answers"`
DimensionSelects map[string]bool `json:"dimension_selects"`
Answers map[string]interface{} `json:"answers"`
DimensionValueKey string `json:"dimension_value_key"`
Selected *bool `json:"selected"`
}
func parseStageConfig(node model.SOPNode) (stageNodeConfig, error) {
var config stageNodeConfig
if len(node.Config) > 0 {
if err := json.Unmarshal(node.Config, &config); err != nil {
return config, fmt.Errorf("当前阶段节点配置不正确")
}
}
if config.StageKey == "" {
return config, errors.New("当前阶段节点没有绑定话术包阶段")
}
return config, nil
}
// loadRunPackage loads the script package that drives a run.
func loadRunPackage(db *gorm.DB, run model.SOPRun) (*scriptkit.Package, error) {
var scenarioID uint64
if err := db.Table("sops s").Select("s.scenario_id").Where("s.id = ? AND s.tenant_id = ?", run.SOPID, run.TenantID).Scan(&scenarioID).Error; err != nil {
return nil, err
}
pkg, err := scriptkit.LoadPackageByScenario(db, run.TenantID, scenarioID)
if err != nil {
return nil, errors.New("场景没有配置话术包")
}
return pkg, nil
}
func runScriptState(run model.SOPRun) scriptkit.ScriptState {
state := scriptkit.ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}
if len(run.ScriptState) > 0 {
_ = json.Unmarshal(run.ScriptState, &state)
}
if state.ScriptAnswers == nil {
state.ScriptAnswers = map[string][]string{}
}
if state.DimensionSelects == nil {
state.DimensionSelects = map[string]bool{}
}
return state
}
func runRenderContext(run model.SOPRun) scriptkit.RenderContext {
input := map[string]interface{}{}
_ = json.Unmarshal(run.Input, &input)
derived := map[string]interface{}{}
_ = json.Unmarshal(run.Derived, &derived)
answers := map[string]interface{}{}
_ = json.Unmarshal(run.Answers, &answers)
return scriptkit.RenderContext{Input: input, Derived: derived, Form: answers}
}
// runWeights deterministically recomputes the current dimension weights.
func runWeights(run model.SOPRun, pkg *scriptkit.Package) map[uint64]int {
ctx := runRenderContext(run)
return pkg.RecomputeWeights(pkg.InitialWeights(ctx), runScriptState(run), ctx)
}
// persistRunWeights rewrites the materialized run_dimensions rows inside the
// current transaction.
func persistRunWeights(tx *gorm.DB, run model.SOPRun, pkg *scriptkit.Package, weights map[uint64]int) error {
if err := tx.Where("tenant_id = ? AND run_id = ?", run.TenantID, run.ID).Delete(&model.RunDimension{}).Error; err != nil {
return err
}
for _, value := range pkg.Values {
row := model.RunDimension{TenantID: run.TenantID, RunID: run.ID, PackageID: pkg.Package.ID, DimensionID: value.DimensionID, ValueKey: value.ValueKey, Weight: weights[value.ID]}
if err := tx.Create(&row).Error; err != nil {
return err
}
}
return nil
}
// buildStageView renders the stage bound to a node for the current run.
func buildStageView(db *gorm.DB, run model.SOPRun, node model.SOPNode) (*scriptkit.StageView, error) {
config, err := parseStageConfig(node)
if err != nil {
return nil, err
}
pkg, err := loadRunPackage(db, run)
if err != nil {
return nil, err
}
stage, ok := pkg.StageByKey(config.StageKey)
if !ok {
return nil, errors.New("当前节点关联的阶段不存在")
}
formFields, err := loadStageFormFields(db, node, run.TenantID, config)
if err != nil {
return nil, err
}
view := pkg.BuildStageView(stage, runWeights(run, pkg), runRenderContext(run), runScriptState(run), formFields)
return &view, nil
}
func loadStageFormFields(db *gorm.DB, node model.SOPNode, tenantID uint64, config stageNodeConfig) ([]scriptkit.FormFieldView, error) {
if len(config.FieldKeys) == 0 {
return []scriptkit.FormFieldView{}, nil
}
var fields []model.ScenarioField
if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPID, tenantID, config.FieldKeys).Find(&fields).Error; err != nil {
return nil, err
}
byKey := map[string]model.ScenarioField{}
for _, field := range fields {
byKey[field.FieldKey] = field
}
views := make([]scriptkit.FormFieldView, 0, len(config.FieldKeys))
for _, key := range config.FieldKeys {
field, ok := byKey[key]
if !ok {
continue
}
required := field.Required || containsString(config.RequiredFieldKeys, key)
views = append(views, scriptkit.FormFieldView{Key: field.FieldKey, Name: field.FieldName, Type: field.FieldType, Required: required, Options: json.RawMessage(field.Options), Validation: json.RawMessage(field.Validation)})
}
return views, nil
}
// applyStageAnswer records one or more stage answers (script answers, dimension
// selects, form fields) in one batch and recomputes the run dimension weights
// once. The run stays on the same node.
func applyStageAnswer(tx *gorm.DB, run *model.SOPRun, node model.SOPNode, input stageAnswerInput) error {
pkg, err := loadRunPackage(tx, *run)
if err != nil {
return err
}
config, err := parseStageConfig(node)
if err != nil {
return err
}
stage, ok := pkg.StageByKey(config.StageKey)
if !ok {
return errors.New("当前节点关联的阶段不存在")
}
state := runScriptState(*run)
answers := map[string]interface{}{}
if len(run.Answers) > 0 {
_ = json.Unmarshal(run.Answers, &answers)
}
if answers == nil {
answers = map[string]interface{}{}
}
ctx := runRenderContext(*run)
base := pkg.InitialWeights(ctx)
changed := false
items := input.ScriptAnswers
if len(items) == 0 && input.ScriptKey != "" {
items = []stageScriptAnswerInput{{ScriptKey: input.ScriptKey, OptionKeys: input.OptionKeys, Value: input.Value}}
}
// 筛选题先处理:它按当前候选症状整体升维/归零,顺序无关但语义上应最先。
for _, item := range items {
if item.ScriptKey == "" {
continue
}
script, found := pkg.ScriptByKey(item.ScriptKey)
if !found || script.StageID != stage.ID {
return fmt.Errorf("话术 %s 不属于当前阶段", item.ScriptKey)
}
if script.ScriptType == "screen" {
if err := recordStageScriptAnswer(pkg, script, &state, answers, ctx, base, item); err != nil {
return err
}
changed = true
}
}
for _, item := range items {
if item.ScriptKey == "" {
continue
}
script, found := pkg.ScriptByKey(item.ScriptKey)
if !found || script.StageID != stage.ID {
return fmt.Errorf("话术 %s 不属于当前阶段", item.ScriptKey)
}
if script.ScriptType == "screen" {
continue
}
if err := recordStageScriptAnswer(pkg, script, &state, answers, ctx, base, item); err != nil {
return err
}
changed = true
}
if input.DimensionValueKey != "" && input.Selected != nil {
if _, ok := pkg.ValueByKey(input.DimensionValueKey); !ok {
return fmt.Errorf("维度值 %s 不存在", input.DimensionValueKey)
}
state.DimensionSelects[input.DimensionValueKey] = *input.Selected
changed = true
}
if len(input.DimensionSelects) > 0 {
for key, selected := range input.DimensionSelects {
if _, ok := pkg.ValueByKey(key); !ok {
return fmt.Errorf("维度值 %s 不存在", key)
}
state.DimensionSelects[key] = selected
}
changed = true
}
if len(input.Answers) > 0 {
if len(config.FieldKeys) == 0 {
return errors.New("当前阶段没有内容补充表单")
}
fields := make([]model.ScenarioField, 0)
if err := tx.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", run.SOPID, run.TenantID).Find(&fields).Error; err != nil {
return err
}
formNode := model.SOPNode{Type: "form", Config: datatypes.JSON(mustJSON(config))}
if err := validateNodeAnswers(formNode, fields, input.Answers); err != nil {
return err
}
for key, value := range input.Answers {
answers[key] = value
}
changed = true
}
if changed {
answersRaw, _ := json.Marshal(answers)
stateRaw, _ := json.Marshal(state)
run.Answers = datatypes.JSON(answersRaw)
run.ScriptState = datatypes.JSON(stateRaw)
if err := tx.Model(run).Updates(map[string]interface{}{"answers": run.Answers, "script_state": run.ScriptState}).Error; err != nil {
return err
}
weights := runWeights(*run, pkg)
if err := persistRunWeights(tx, *run, pkg, weights); err != nil {
return err
}
payload, _ := json.Marshal(input)
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "answer", Payload: datatypes.JSON(payload)}).Error
}
return nil
}
// recordStageScriptAnswer applies one script answer to the in-memory state.
// Weights are not persisted here; the caller recomputes them once after the
// whole batch is applied.
func recordStageScriptAnswer(pkg *scriptkit.Package, script model.StageScript, state *scriptkit.ScriptState, answers map[string]interface{}, ctx scriptkit.RenderContext, base map[uint64]int, item stageScriptAnswerInput) error {
switch {
case item.OptionKeys != nil && script.ScriptType == "screen":
// 症状筛选题:多选。被选中的症状确认并进入细节,
// 未选中的候选症状统一归零,不再追问。
selected := map[uint64]bool{}
for _, key := range item.OptionKeys {
option, ok := pkg.OptionByKey(script, key)
if !ok {
return fmt.Errorf("话术 %s 的选项 %s 不存在", item.ScriptKey, key)
}
if option.TargetDimensionValueID != nil {
selected[*option.TargetDimensionValueID] = true
}
}
if script.CollectFieldKey != "" {
labels := make([]string, 0, len(item.OptionKeys))
for _, key := range item.OptionKeys {
if option, ok := pkg.OptionByKey(script, key); ok {
labels = append(labels, option.Label)
}
}
answers[script.CollectFieldKey] = strings.Join(labels, "、")
}
state.ScriptAnswers[item.ScriptKey] = item.OptionKeys
dimensionID := screenScriptDimension(pkg, script)
if dimensionID != 0 {
current := pkg.RecomputeWeights(base, *state, ctx)
for _, value := range pkg.Values {
if value.DimensionID == dimensionID && current[value.ID] > 0 {
state.DimensionSelects[value.ValueKey] = selected[value.ID]
}
}
}
case item.OptionKeys != nil:
if script.ScriptType != "confirm" && script.ScriptType != "choice" && script.ScriptType != "info" {
return fmt.Errorf("话术 %s 不接受选项回答", item.ScriptKey)
}
// 选择题支持多选和全部取消;确认题至少需要一个选项。
if len(item.OptionKeys) == 0 && script.ScriptType != "choice" {
return errors.New("回答缺少选项")
}
for _, key := range item.OptionKeys {
if _, ok := pkg.OptionByKey(script, key); !ok {
return fmt.Errorf("话术 %s 的选项 %s 不存在", item.ScriptKey, key)
}
}
if script.CollectFieldKey != "" {
labels := make([]string, 0, len(item.OptionKeys))
for _, key := range item.OptionKeys {
if option, ok := pkg.OptionByKey(script, key); ok {
labels = append(labels, option.Label)
}
}
answers[script.CollectFieldKey] = strings.Join(labels, "、")
}
state.ScriptAnswers[item.ScriptKey] = item.OptionKeys
case item.Value != "":
if script.CollectFieldKey == "" {
return fmt.Errorf("话术 %s 不采集信息", item.ScriptKey)
}
answers[script.CollectFieldKey] = item.Value
state.ScriptAnswers[item.ScriptKey] = []string{item.Value}
default:
return errors.New("回答缺少选项或内容")
}
return nil
}
// screenScriptDimension returns the dimension targeted by a screening script's
// options, or zero when the script has no dimension targets.
func screenScriptDimension(pkg *scriptkit.Package, script model.StageScript) uint64 {
for _, option := range pkg.Options {
if option.ScriptID != script.ID || option.TargetDimensionValueID == nil {
continue
}
for _, value := range pkg.Values {
if value.ID == *option.TargetDimensionValueID {
return value.DimensionID
}
}
}
return 0
}
func mustJSON(value interface{}) []byte {
raw, _ := json.Marshal(value)
return raw
}
// stageCanNext reports whether the current stage view allows advancing.
func stageCanNext(db *gorm.DB, run model.SOPRun, node model.SOPNode) (bool, error) {
view, err := buildStageView(db, run, node)
if err != nil {
return false, err
}
return view.CanNext, nil
}
// advanceFromStage moves the run to the next node when the stage is complete.
func advanceFromStage(tx *gorm.DB, run *model.SOPRun, node model.SOPNode) error {
view, err := buildStageView(tx, *run, node)
if err != nil {
return err
}
if !view.CanNext {
if view.CanNextReason != "" {
return errors.New(view.CanNextReason)
}
return errors.New("当前阶段还有必填内容未完成")
}
return advanceRunNode(tx, run)
}
// advanceRunNode moves the run to the next node matched by edges.
func advanceRunNode(tx *gorm.DB, run *model.SOPRun) error {
var edges []model.SOPEdge
if err := tx.Where("sop_id = ? AND source_node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).Order("priority, id").Find(&edges).Error; err != nil {
return err
}
context := runtimeContext(mapsFromRun(*run))
sortEdges(edges)
nextKey := ""
for _, edge := range edges {
matched, err := matchCondition(json.RawMessage(edge.Condition), context)
if err != nil {
return err
}
if matched {
nextKey = edge.TargetNodeKey
break
}
}
if nextKey == "" {
return errors.New("没有满足条件的下一节点")
}
var next model.SOPNode
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, nextKey, run.TenantID).First(&next).Error; err != nil {
return err
}
updates := map[string]interface{}{"current_node_key": nextKey}
if next.Type == "finish" || next.Type == "escalate" {
now := time.Now()
updates["status"] = "completed"
updates["completed_at"] = &now
updates["result"] = next.Type
run.Status, run.CompletedAt, run.Result = "completed", &now, next.Type
}
if err := tx.Model(run).Updates(updates).Error; err != nil {
return err
}
run.CurrentNodeKey = nextKey
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
return err
}
if run.Status == "completed" {
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "finish", Payload: datatypes.JSON([]byte(`{"source":"terminal_node"}`))}).Error
}
return nil
}
// backRunNode moves the run to the previous node via reverse edges.
func backRunNode(tx *gorm.DB, run *model.SOPRun) error {
var edges []model.SOPEdge
if err := tx.Where("sop_id = ? AND target_node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).Order("priority, id").Find(&edges).Error; err != nil {
return err
}
if len(edges) == 0 {
return errors.New("当前已经是第一步")
}
prevKey := edges[0].SourceNodeKey
if err := tx.Model(run).Update("current_node_key", prevKey).Error; err != nil {
return err
}
run.CurrentNodeKey = prevKey
return nil
}