update
This commit is contained in:
462
internal/run/stage.go
Normal file
462
internal/run/stage.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user