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 }