463 lines
16 KiB
Go
463 lines
16 KiB
Go
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
|
|
}
|