update
This commit is contained in:
697
internal/scriptkit/engine.go
Normal file
697
internal/scriptkit/engine.go
Normal file
@@ -0,0 +1,697 @@
|
||||
// Package scriptkit implements the script-package engine: dimensions with
|
||||
// weights, stage scripts, up/down weighting and dimension linkage. It replaces
|
||||
// the legacy knowledge graph for the consultation scenario.
|
||||
package scriptkit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SelectWeight is the weight assigned when a dimension value is selected
|
||||
// directly (manual check). It exceeds every script show threshold.
|
||||
const SelectWeight = 10
|
||||
|
||||
// templatePattern matches {{input.x}} / {{derived.x}} / {{form.x}} placeholders.
|
||||
var templatePattern = regexp.MustCompile(`\{\{\s*(input|derived|form)\.([A-Za-z][A-Za-z0-9_]*)\s*\}\}`)
|
||||
|
||||
// RenderContext provides the values available for script templates.
|
||||
type RenderContext struct {
|
||||
Input map[string]interface{}
|
||||
Derived map[string]interface{}
|
||||
Form map[string]interface{}
|
||||
}
|
||||
|
||||
// ScriptState is the deterministic answer state of a run. It is the only
|
||||
// source of truth for dimension weights; run_dimensions is a materialized
|
||||
// cache of RecomputeWeights.
|
||||
type ScriptState struct {
|
||||
// ScriptAnswers maps script_key -> selected option keys (or collected
|
||||
// values for info scripts).
|
||||
ScriptAnswers map[string][]string `json:"script_answers"`
|
||||
// DimensionSelects maps dimension value_key -> selected (manual check).
|
||||
DimensionSelects map[string]bool `json:"dimension_selects"`
|
||||
}
|
||||
|
||||
// Package is a fully loaded script package.
|
||||
type Package struct {
|
||||
Package model.ScriptPackage
|
||||
Dimensions []model.PackageDimension
|
||||
Values []model.DimensionValue
|
||||
Stages []model.PackageStage
|
||||
Scripts []model.StageScript
|
||||
Options []model.ScriptOption
|
||||
Linkages []model.DimensionLinkage
|
||||
Adapters []model.PackageAdapter
|
||||
}
|
||||
|
||||
// StageView is the rendered view of one stage for a run. It is the complete
|
||||
// question set of the stage: clients answer locally and submit all answers
|
||||
// once at the end of the stage, revealing follow-up questions themselves
|
||||
// from the per-script confirm/show thresholds. ScreeningRequired marks
|
||||
// symptom stages with more than 3 candidates, where the screen question is
|
||||
// answered before the per-symptom questions.
|
||||
type StageView struct {
|
||||
StageKey string `json:"stage_key"`
|
||||
Name string `json:"name"`
|
||||
Purpose string `json:"purpose"`
|
||||
Dimensions []DimensionView `json:"dimensions"`
|
||||
Scripts []ScriptView `json:"scripts"`
|
||||
Fallback string `json:"fallback,omitempty"`
|
||||
Form *FormView `json:"form,omitempty"`
|
||||
ScreeningRequired bool `json:"screening_required"`
|
||||
CanNext bool `json:"can_next"`
|
||||
CanNextReason string `json:"can_next_reason,omitempty"`
|
||||
}
|
||||
|
||||
// DimensionView is one dimension and its weighted values.
|
||||
type DimensionView struct {
|
||||
DimKey string `json:"dim_key"`
|
||||
Name string `json:"name"`
|
||||
Values []ValueView `json:"values"`
|
||||
}
|
||||
|
||||
// ValueView is one dimension value with its current weight.
|
||||
type ValueView struct {
|
||||
ValueKey string `json:"value_key"`
|
||||
Name string `json:"name"`
|
||||
Weight int `json:"weight"`
|
||||
Hot bool `json:"hot"`
|
||||
}
|
||||
|
||||
// ScriptView is one matched script rendered for the client. Weight and
|
||||
// thresholds let clients run the progressive reveal locally and submit all
|
||||
// answers once at the end of the stage.
|
||||
type ScriptView struct {
|
||||
ScriptKey string `json:"script_key"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"script_type"`
|
||||
Content string `json:"content"`
|
||||
DimensionValueKey string `json:"dimension_value_key,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
ConfirmThreshold int `json:"confirm_threshold,omitempty"`
|
||||
ShowThreshold int `json:"show_threshold,omitempty"`
|
||||
Options []OptionView `json:"options,omitempty"`
|
||||
Products []ProductView `json:"products,omitempty"`
|
||||
Feedback bool `json:"feedback"`
|
||||
Required bool `json:"required"`
|
||||
Multiple bool `json:"multiple"`
|
||||
}
|
||||
|
||||
// ProductView is one static product attached to a recommendation script.
|
||||
type ProductView struct {
|
||||
SKUCode string `json:"sku_code"`
|
||||
ProductName string `json:"product_name"`
|
||||
}
|
||||
|
||||
// OptionView is one answer option of a script.
|
||||
type OptionView struct {
|
||||
OptionKey string `json:"option_key"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// FormView is the content supplement form of a stage.
|
||||
type FormView struct {
|
||||
Fields []FormFieldView `json:"fields"`
|
||||
}
|
||||
|
||||
// FormFieldView mirrors scenario field definitions for the client.
|
||||
type FormFieldView struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
Options json.RawMessage `json:"options"`
|
||||
Validation json.RawMessage `json:"validation"`
|
||||
}
|
||||
|
||||
// LoadPackageByID loads a script package with all children.
|
||||
func LoadPackageByID(db *gorm.DB, tenantID, packageID uint64) (*Package, error) {
|
||||
var row model.ScriptPackage
|
||||
if err := db.Where("id = ? AND tenant_id = ?", packageID, tenantID).First(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loadPackage(db, tenantID, row)
|
||||
}
|
||||
|
||||
// LoadPackageByScenario loads the package owned by a scenario.
|
||||
func LoadPackageByScenario(db *gorm.DB, tenantID, scenarioID uint64) (*Package, error) {
|
||||
var row model.ScriptPackage
|
||||
if err := db.Where("scenario_id = ? AND tenant_id = ?", scenarioID, tenantID).First(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loadPackage(db, tenantID, row)
|
||||
}
|
||||
|
||||
func loadPackage(db *gorm.DB, tenantID uint64, row model.ScriptPackage) (*Package, error) {
|
||||
pkg := &Package{Package: row}
|
||||
queries := []struct {
|
||||
target interface{}
|
||||
order string
|
||||
}{
|
||||
{&pkg.Dimensions, "sort_order, id"},
|
||||
{&pkg.Values, "sort_order, id"},
|
||||
{&pkg.Stages, "sort_order, id"},
|
||||
{&pkg.Scripts, "sort_order, id"},
|
||||
{&pkg.Options, "sort_order, id"},
|
||||
{&pkg.Linkages, "sort_order, id"},
|
||||
{&pkg.Adapters, "sort_order, id"},
|
||||
}
|
||||
for _, query := range queries {
|
||||
if err := db.Where("package_id = ? AND tenant_id = ?", row.ID, tenantID).Order(query.order).Find(query.target).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
// StageByKey returns the stage with the given key.
|
||||
func (p *Package) StageByKey(stageKey string) (model.PackageStage, bool) {
|
||||
for _, stage := range p.Stages {
|
||||
if stage.StageKey == stageKey {
|
||||
return stage, true
|
||||
}
|
||||
}
|
||||
return model.PackageStage{}, false
|
||||
}
|
||||
|
||||
// ValueByKey returns the dimension value with the given key.
|
||||
func (p *Package) ValueByKey(valueKey string) (model.DimensionValue, bool) {
|
||||
for _, value := range p.Values {
|
||||
if value.ValueKey == valueKey {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return model.DimensionValue{}, false
|
||||
}
|
||||
|
||||
// ScriptByKey returns the script with the given key.
|
||||
func (p *Package) ScriptByKey(scriptKey string) (model.StageScript, bool) {
|
||||
for _, script := range p.Scripts {
|
||||
if script.ScriptKey == scriptKey {
|
||||
return script, true
|
||||
}
|
||||
}
|
||||
return model.StageScript{}, false
|
||||
}
|
||||
|
||||
// OptionByKey returns the option of a script with the given key.
|
||||
func (p *Package) OptionByKey(script model.StageScript, optionKey string) (model.ScriptOption, bool) {
|
||||
for _, option := range p.Options {
|
||||
if option.ScriptID == script.ID && option.OptionKey == optionKey {
|
||||
return option, true
|
||||
}
|
||||
}
|
||||
return model.ScriptOption{}, false
|
||||
}
|
||||
|
||||
// InitialWeights computes the entry weights from package adapters.
|
||||
func (p *Package) InitialWeights(ctx RenderContext) map[uint64]int {
|
||||
weights := make(map[uint64]int)
|
||||
for _, adapter := range p.Adapters {
|
||||
if adapterMatches(adapter, ctx) {
|
||||
if current, exists := weights[adapter.TargetDimensionValueID]; !exists || current < adapter.Weight {
|
||||
weights[adapter.TargetDimensionValueID] = adapter.Weight
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, value := range p.Values {
|
||||
if value.InitialWeight > 0 {
|
||||
if current, exists := weights[value.ID]; !exists || current < value.InitialWeight {
|
||||
weights[value.ID] = value.InitialWeight
|
||||
}
|
||||
}
|
||||
}
|
||||
return weights
|
||||
}
|
||||
|
||||
func adapterMatches(adapter model.PackageAdapter, ctx RenderContext) bool {
|
||||
value, ok := lookupContext(ctx, adapter.SourceField)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case []interface{}:
|
||||
for _, item := range typed {
|
||||
if fmt.Sprint(item) == adapter.MatchValue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case []string:
|
||||
for _, item := range typed {
|
||||
if item == adapter.MatchValue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return fmt.Sprint(typed) == adapter.MatchValue
|
||||
}
|
||||
}
|
||||
|
||||
// RecomputeWeights deterministically derives the current weights from the
|
||||
// initial weights plus the script answer state and dimension linkage. Values
|
||||
// explicitly set by a user answer (set effect or dimension select) are marked
|
||||
// confirmed and no longer receive linkage contributions, so a confirmed
|
||||
// disease keeps weight 10 instead of stacking symptom evidence on top.
|
||||
func (p *Package) RecomputeWeights(base map[uint64]int, state ScriptState, ctx RenderContext) map[uint64]int {
|
||||
weights := cloneWeights(base)
|
||||
confirmed := map[uint64]bool{}
|
||||
for scriptKey, optionKeys := range state.ScriptAnswers {
|
||||
script, ok := p.ScriptByKey(scriptKey)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, optionKey := range optionKeys {
|
||||
option, found := p.OptionByKey(script, optionKey)
|
||||
if !found || option.TargetDimensionValueID == nil {
|
||||
continue
|
||||
}
|
||||
applyEffect(weights, *option.TargetDimensionValueID, option.Effect, option.EffectValue)
|
||||
if option.Effect == "set" {
|
||||
confirmed[*option.TargetDimensionValueID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for valueKey, selected := range state.DimensionSelects {
|
||||
value, ok := p.ValueByKey(valueKey)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if selected {
|
||||
if current := weights[value.ID]; current < SelectWeight {
|
||||
weights[value.ID] = SelectWeight
|
||||
}
|
||||
confirmed[value.ID] = true
|
||||
} else {
|
||||
weights[value.ID] = 0
|
||||
delete(confirmed, value.ID)
|
||||
}
|
||||
}
|
||||
return p.propagate(weights, confirmed)
|
||||
}
|
||||
|
||||
// linkageDefaultThreshold is the minimum source weight for a dimension value
|
||||
// to count as confirmed and propagate through linkages.
|
||||
const linkageDefaultThreshold = 5
|
||||
|
||||
// propagate spreads confirmed dimension values through dimension linkages until
|
||||
// stable. A source only propagates when its weight reaches the linkage
|
||||
// activation threshold, so unconfirmed hints (for example a SKU related symptom
|
||||
// at weight 3) never pull in their diseases. Targets that were explicitly
|
||||
// confirmed by the user keep their own weight and are not stacked again. Each
|
||||
// pass recomputes target weights from the direct weights plus the contributions
|
||||
// of sources that were confirmed in the previous pass, which is stable for DAG
|
||||
// shaped packages.
|
||||
func (p *Package) propagate(direct map[uint64]int, confirmed map[uint64]bool) map[uint64]int {
|
||||
if len(p.Linkages) == 0 {
|
||||
return cloneWeights(direct)
|
||||
}
|
||||
weights := cloneWeights(direct)
|
||||
for pass := 0; pass <= len(p.Linkages)+2; pass++ {
|
||||
next := cloneWeights(direct)
|
||||
for _, linkage := range p.Linkages {
|
||||
threshold := linkage.ActivationThreshold
|
||||
if threshold <= 0 {
|
||||
threshold = linkageDefaultThreshold
|
||||
}
|
||||
if confirmed[linkage.ToDimensionValueID] {
|
||||
continue
|
||||
}
|
||||
if weights[linkage.FromDimensionValueID] >= threshold {
|
||||
next[linkage.ToDimensionValueID] += linkage.Contribution
|
||||
}
|
||||
}
|
||||
if weightsEqual(next, weights) {
|
||||
return next
|
||||
}
|
||||
weights = next
|
||||
}
|
||||
return weights
|
||||
}
|
||||
|
||||
func applyEffect(weights map[uint64]int, target uint64, effect string, value int) {
|
||||
switch effect {
|
||||
case "set":
|
||||
weights[target] = value
|
||||
case "add":
|
||||
weights[target] += value
|
||||
case "subtract":
|
||||
weights[target] -= value
|
||||
if weights[target] < 0 {
|
||||
weights[target] = 0
|
||||
}
|
||||
case "zero":
|
||||
weights[target] = 0
|
||||
default:
|
||||
weights[target] = value
|
||||
}
|
||||
}
|
||||
|
||||
// BuildStageView matches and renders the scripts of one stage. The view is the
|
||||
// complete question set for the stage: clients answer locally and submit once
|
||||
// at the end of the stage, revealing follow-up questions themselves from the
|
||||
// confirm/show thresholds. ScreeningRequired tells symptom stages with more
|
||||
// than 3 candidates to show the screen question first.
|
||||
func (p *Package) BuildStageView(stage model.PackageStage, weights map[uint64]int, ctx RenderContext, state ScriptState, formFields []FormFieldView) StageView {
|
||||
view := StageView{
|
||||
StageKey: stage.StageKey, Name: stage.Name, Purpose: stage.Purpose,
|
||||
Dimensions: []DimensionView{}, Scripts: []ScriptView{}, CanNext: true,
|
||||
}
|
||||
view.Dimensions = p.buildDimensions(stage, weights)
|
||||
primaryZero := p.primaryDimensionZero(stage, weights)
|
||||
scripts := p.scriptsForStage(stage)
|
||||
screening := p.findScript(scripts, "screen")
|
||||
view.ScreeningRequired = screening != nil && p.candidateCount(stage, weights) > 3
|
||||
matches := make([]scriptMatch, 0)
|
||||
for _, script := range scripts {
|
||||
weight, included := p.scriptIncluded(script, stage, weights, primaryZero)
|
||||
if !included {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, scriptMatch{script: script, weight: weight})
|
||||
}
|
||||
sort.SliceStable(matches, func(i, j int) bool {
|
||||
if matches[i].weight != matches[j].weight {
|
||||
return matches[i].weight > matches[j].weight
|
||||
}
|
||||
return matches[i].script.SortOrder < matches[j].script.SortOrder
|
||||
})
|
||||
for _, match := range matches {
|
||||
script := match.script
|
||||
rendered := RenderTemplate(script.Content, ctx)
|
||||
view.Scripts = append(view.Scripts, ScriptView{
|
||||
ScriptKey: script.ScriptKey, Name: script.Name, Type: script.ScriptType,
|
||||
Content: rendered, DimensionValueKey: p.valueKey(script.DimensionValueID),
|
||||
Weight: match.weight, ConfirmThreshold: script.ConfirmThreshold, ShowThreshold: script.ShowThreshold,
|
||||
Options: p.optionsFor(script),
|
||||
Products: p.productsFor(script),
|
||||
Feedback: script.ScriptType != "template", Required: script.Required, Multiple: script.Multiple,
|
||||
})
|
||||
if script.ScriptType == "fallback" {
|
||||
view.Fallback = rendered
|
||||
}
|
||||
if script.Required && !stateAnswered(state, script) {
|
||||
view.CanNext = false
|
||||
}
|
||||
}
|
||||
// 拟诊阶段必须至少确认一个疾病方向,否则药品推荐节点会没有药物推荐。
|
||||
if stage.StageKey == "diagnosis" && !p.stageHasConfirmedPrimary(stage, weights) {
|
||||
view.CanNext = false
|
||||
view.CanNextReason = "请至少确认一个疾病方向(或手动勾选一个),确认后才能进入药品推荐"
|
||||
}
|
||||
if len(formFields) > 0 {
|
||||
view.Form = &FormView{Fields: formFields}
|
||||
for _, field := range formFields {
|
||||
if field.Required && isEmptyValue(ctx.Form[field.Key]) {
|
||||
view.CanNext = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// scriptIncluded decides whether a script belongs to the complete stage view
|
||||
// and returns its sort weight. Confirmed questions disappear once the value is
|
||||
// confirmed; follow-up scripts stay included so clients can reveal them
|
||||
// locally after the user answers.
|
||||
func (p *Package) scriptIncluded(script model.StageScript, stage model.PackageStage, weights map[uint64]int, primaryZero bool) (int, bool) {
|
||||
if script.DimensionValueID == nil {
|
||||
if script.ScriptType == "fallback" {
|
||||
return 0, primaryZero
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
weight := weights[*script.DimensionValueID]
|
||||
if weight <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
switch script.ScriptType {
|
||||
case "confirm":
|
||||
if weight >= script.ShowThreshold {
|
||||
return weight, false
|
||||
}
|
||||
return weight, true
|
||||
case "choice", "info":
|
||||
return weight, true
|
||||
case "message":
|
||||
// 推荐阶段只带已确认(权重达到显示阈值)的疾病;拟诊阶段把确认后
|
||||
// 话术一并带出,由客户端在本地确认后展示。
|
||||
if stage.StageKey == "recommend" && weight < script.ShowThreshold {
|
||||
return weight, false
|
||||
}
|
||||
return weight, true
|
||||
default:
|
||||
return weight, true
|
||||
}
|
||||
}
|
||||
|
||||
// findScript returns the first stage script with the given type.
|
||||
func (p *Package) findScript(scripts []model.StageScript, scriptType string) *model.StageScript {
|
||||
for index := range scripts {
|
||||
if scripts[index].ScriptType == scriptType {
|
||||
return &scripts[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// candidateCount counts the primary dimension values that currently carry a
|
||||
// weight greater than zero.
|
||||
func (p *Package) candidateCount(stage model.PackageStage, weights map[uint64]int) int {
|
||||
count := 0
|
||||
for _, value := range p.Values {
|
||||
if value.DimensionID == stage.PrimaryDimensionID && weights[value.ID] > 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// stageHasConfirmedPrimary reports whether at least one primary dimension value
|
||||
// reached the confirmation threshold of the stage's confirm scripts. Stages
|
||||
// without confirm scripts are not constrained. For the diagnosis stage this
|
||||
// guarantees the recommendation node always has confirmed diseases to
|
||||
// recommend, so entering it always comes with product recommendations.
|
||||
func (p *Package) stageHasConfirmedPrimary(stage model.PackageStage, weights map[uint64]int) bool {
|
||||
threshold := 0
|
||||
for _, script := range p.Scripts {
|
||||
if script.StageID == stage.ID && script.ScriptType == "confirm" && script.ShowThreshold > threshold {
|
||||
threshold = script.ShowThreshold
|
||||
}
|
||||
}
|
||||
if threshold <= 0 {
|
||||
return true
|
||||
}
|
||||
for _, value := range p.Values {
|
||||
if value.DimensionID == stage.PrimaryDimensionID && weights[value.ID] >= threshold {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type scriptMatch struct {
|
||||
script model.StageScript
|
||||
weight int
|
||||
}
|
||||
|
||||
// buildDimensions renders only the primary dimension of the stage. Symptoms
|
||||
// therefore only appear in the symptom stage and diseases only in the
|
||||
// diagnosis/recommend stages. A stage without a primary dimension (opening)
|
||||
// renders no dimension chips.
|
||||
func (p *Package) buildDimensions(stage model.PackageStage, weights map[uint64]int) []DimensionView {
|
||||
if stage.PrimaryDimensionID == 0 {
|
||||
return []DimensionView{}
|
||||
}
|
||||
views := make([]DimensionView, 0, 1)
|
||||
for _, dimension := range p.Dimensions {
|
||||
if dimension.ID != stage.PrimaryDimensionID {
|
||||
continue
|
||||
}
|
||||
values := make([]ValueView, 0)
|
||||
for _, value := range p.Values {
|
||||
if value.DimensionID != dimension.ID {
|
||||
continue
|
||||
}
|
||||
values = append(values, ValueView{ValueKey: value.ValueKey, Name: value.Name, Weight: weights[value.ID], Hot: weights[value.ID] > 0})
|
||||
}
|
||||
sort.SliceStable(values, func(i, j int) bool {
|
||||
if values[i].Weight != values[j].Weight {
|
||||
return values[i].Weight > values[j].Weight
|
||||
}
|
||||
return values[i].ValueKey < values[j].ValueKey
|
||||
})
|
||||
views = append(views, DimensionView{DimKey: dimension.DimKey, Name: dimension.Name, Values: values})
|
||||
break
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func (p *Package) primaryDimensionZero(stage model.PackageStage, weights map[uint64]int) bool {
|
||||
for _, value := range p.Values {
|
||||
if value.DimensionID == stage.PrimaryDimensionID && weights[value.ID] > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Package) scriptsForStage(stage model.PackageStage) []model.StageScript {
|
||||
scripts := make([]model.StageScript, 0)
|
||||
for _, script := range p.Scripts {
|
||||
if script.StageID == stage.ID {
|
||||
scripts = append(scripts, script)
|
||||
}
|
||||
}
|
||||
return scripts
|
||||
}
|
||||
|
||||
func (p *Package) optionsFor(script model.StageScript) []OptionView {
|
||||
if script.ScriptType != "confirm" && script.ScriptType != "choice" && script.ScriptType != "info" && script.ScriptType != "screen" {
|
||||
return nil
|
||||
}
|
||||
options := make([]OptionView, 0)
|
||||
for _, option := range p.Options {
|
||||
if option.ScriptID != script.ID {
|
||||
continue
|
||||
}
|
||||
options = append(options, OptionView{OptionKey: option.OptionKey, Label: option.Label})
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func (p *Package) valueKey(valueID *uint64) string {
|
||||
if valueID == nil {
|
||||
return ""
|
||||
}
|
||||
for _, value := range p.Values {
|
||||
if value.ID == *valueID {
|
||||
return value.ValueKey
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *Package) productsFor(script model.StageScript) []ProductView {
|
||||
if len(script.Products) == 0 || string(script.Products) == "[]" || string(script.Products) == "{}" {
|
||||
return nil
|
||||
}
|
||||
var products []ProductView
|
||||
if err := json.Unmarshal(script.Products, &products); err != nil {
|
||||
return nil
|
||||
}
|
||||
return products
|
||||
}
|
||||
|
||||
// stateAnswered reports whether a script has an answer entry. The key presence
|
||||
// counts as answered so a multi-select question can be answered with an empty
|
||||
// selection.
|
||||
func stateAnswered(state ScriptState, script model.StageScript) bool {
|
||||
_, ok := state.ScriptAnswers[script.ScriptKey]
|
||||
return ok
|
||||
}
|
||||
|
||||
// RenderTemplate replaces {{input.x}} / {{derived.x}} / {{form.x}} with the
|
||||
// current context values. Missing values render as empty strings so callers
|
||||
// can fall back to generic copy.
|
||||
func RenderTemplate(content string, ctx RenderContext) string {
|
||||
return templatePattern.ReplaceAllStringFunc(content, func(match string) string {
|
||||
parts := templatePattern.FindStringSubmatch(match)
|
||||
if len(parts) != 3 {
|
||||
return ""
|
||||
}
|
||||
value, ok := lookupContext(ctx, parts[1]+"."+parts[2])
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
return formatTemplateValue(value)
|
||||
})
|
||||
}
|
||||
|
||||
// formatTemplateValue renders a context value for template interpolation.
|
||||
// Arrays are joined with "、" for natural Chinese copy.
|
||||
func formatTemplateValue(value interface{}) string {
|
||||
switch typed := value.(type) {
|
||||
case []interface{}:
|
||||
parts := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
parts = append(parts, fmt.Sprint(item))
|
||||
}
|
||||
return strings.Join(parts, "、")
|
||||
case []string:
|
||||
return strings.Join(typed, "、")
|
||||
default:
|
||||
return fmt.Sprint(typed)
|
||||
}
|
||||
}
|
||||
|
||||
// lookupContext resolves a namespaced field (input.x / derived.x / form.x)
|
||||
// or a bare field name.
|
||||
func lookupContext(ctx RenderContext, field string) (interface{}, bool) {
|
||||
if strings.HasPrefix(field, "input.") {
|
||||
value, ok := ctx.Input[strings.TrimPrefix(field, "input.")]
|
||||
return value, ok
|
||||
}
|
||||
if strings.HasPrefix(field, "derived.") {
|
||||
value, ok := ctx.Derived[strings.TrimPrefix(field, "derived.")]
|
||||
return value, ok
|
||||
}
|
||||
if strings.HasPrefix(field, "form.") {
|
||||
value, ok := ctx.Form[strings.TrimPrefix(field, "form.")]
|
||||
return value, ok
|
||||
}
|
||||
if value, ok := ctx.Form[field]; ok {
|
||||
return value, true
|
||||
}
|
||||
if value, ok := ctx.Input[field]; ok {
|
||||
return value, true
|
||||
}
|
||||
if value, ok := ctx.Derived[field]; ok {
|
||||
return value, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func cloneWeights(source map[uint64]int) map[uint64]int {
|
||||
clone := make(map[uint64]int, len(source))
|
||||
for key, value := range source {
|
||||
clone[key] = value
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func weightsEqual(left, right map[uint64]int) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for key, value := range left {
|
||||
if right[key] != value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isEmptyValue(value interface{}) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed) == ""
|
||||
case []interface{}:
|
||||
return len(typed) == 0
|
||||
case []string:
|
||||
return len(typed) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
321
internal/scriptkit/engine_test.go
Normal file
321
internal/scriptkit/engine_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package scriptkit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
)
|
||||
|
||||
func buildTestPackage() *Package {
|
||||
// symptom dimension: vomiting, diarrhea
|
||||
// disease dimension: gastritis, enteritis
|
||||
pkg := &Package{Package: model.ScriptPackage{Base: model.Base{ID: 1}, StartStageKey: "symptom"}}
|
||||
pkg.Dimensions = []model.PackageDimension{
|
||||
{Base: model.Base{ID: 1}, PackageID: 1, DimKey: "symptom", Name: "症状"},
|
||||
{Base: model.Base{ID: 2}, PackageID: 1, DimKey: "disease", Name: "疾病"},
|
||||
}
|
||||
pkg.Values = []model.DimensionValue{
|
||||
{Base: model.Base{ID: 11}, PackageID: 1, DimensionID: 1, ValueKey: "vomiting", Name: "呕吐"},
|
||||
{Base: model.Base{ID: 12}, PackageID: 1, DimensionID: 1, ValueKey: "diarrhea", Name: "腹泻"},
|
||||
{Base: model.Base{ID: 21}, PackageID: 1, DimensionID: 2, ValueKey: "gastritis", Name: "胃肠炎"},
|
||||
{Base: model.Base{ID: 22}, PackageID: 1, DimensionID: 2, ValueKey: "enteritis", Name: "肠炎"},
|
||||
}
|
||||
pkg.Stages = []model.PackageStage{
|
||||
{Base: model.Base{ID: 31}, PackageID: 1, StageKey: "symptom", Name: "症状确认", PrimaryDimensionID: 1},
|
||||
{Base: model.Base{ID: 32}, PackageID: 1, StageKey: "diagnosis", Name: "拟诊确认", PrimaryDimensionID: 2},
|
||||
}
|
||||
vomiting := uint64(11)
|
||||
diarrhea := uint64(12)
|
||||
pkg.Scripts = []model.StageScript{
|
||||
{Base: model.Base{ID: 101}, PackageID: 1, StageID: 31, ScriptKey: "vomiting_info", Name: "呕吐细节", ScriptType: "info", Content: "吐的是什么?", DimensionValueID: &vomiting, ShowThreshold: 5, ConfirmThreshold: 3},
|
||||
{Base: model.Base{ID: 102}, PackageID: 1, StageID: 31, ScriptKey: "vomiting_confirm", Name: "确认呕吐", ScriptType: "confirm", Content: "有呕吐吗?", DimensionValueID: &vomiting, ShowThreshold: 5, ConfirmThreshold: 3},
|
||||
{Base: model.Base{ID: 103}, PackageID: 1, StageID: 31, ScriptKey: "diarrhea_confirm", Name: "确认腹泻", ScriptType: "confirm", Content: "有腹泻吗?", DimensionValueID: &diarrhea, ShowThreshold: 5, ConfirmThreshold: 3},
|
||||
{Base: model.Base{ID: 104}, PackageID: 1, StageID: 31, ScriptKey: "symptom_fallback", Name: "兜底", ScriptType: "fallback", Content: "哪里不舒服?"},
|
||||
{Base: model.Base{ID: 105}, PackageID: 1, StageID: 32, ScriptKey: "gastritis_confirm", Name: "确认胃肠炎", ScriptType: "confirm", Content: "更像胃肠炎吗?", DimensionValueID: u64(21), ShowThreshold: 10, ConfirmThreshold: 5},
|
||||
{Base: model.Base{ID: 106}, PackageID: 1, StageID: 32, ScriptKey: "gastritis_msg", Name: "胃肠炎话术", ScriptType: "message", Content: "更符合胃肠炎方向。", DimensionValueID: u64(21), ShowThreshold: 10, ConfirmThreshold: 5},
|
||||
}
|
||||
pkg.Options = []model.ScriptOption{
|
||||
{Base: model.Base{ID: 201}, PackageID: 1, ScriptID: 102, OptionKey: "yes", Label: "有", TargetDimensionValueID: &vomiting, Effect: "set", EffectValue: 8},
|
||||
{Base: model.Base{ID: 202}, PackageID: 1, ScriptID: 102, OptionKey: "no", Label: "没有", TargetDimensionValueID: &vomiting, Effect: "zero"},
|
||||
{Base: model.Base{ID: 203}, PackageID: 1, ScriptID: 103, OptionKey: "yes", Label: "有", TargetDimensionValueID: &diarrhea, Effect: "set", EffectValue: 8},
|
||||
{Base: model.Base{ID: 204}, PackageID: 1, ScriptID: 103, OptionKey: "no", Label: "没有", TargetDimensionValueID: &diarrhea, Effect: "zero"},
|
||||
{Base: model.Base{ID: 205}, PackageID: 1, ScriptID: 105, OptionKey: "yes", Label: "对,是这样", TargetDimensionValueID: u64(21), Effect: "set", EffectValue: 10},
|
||||
{Base: model.Base{ID: 206}, PackageID: 1, ScriptID: 105, OptionKey: "no", Label: "不是这样", TargetDimensionValueID: u64(21), Effect: "zero"},
|
||||
}
|
||||
pkg.Linkages = []model.DimensionLinkage{
|
||||
{Base: model.Base{ID: 301}, PackageID: 1, FromDimensionValueID: 11, ToDimensionValueID: 21, RelationType: "contributes_to", Contribution: 5},
|
||||
{Base: model.Base{ID: 302}, PackageID: 1, FromDimensionValueID: 12, ToDimensionValueID: 21, RelationType: "contributes_to", Contribution: 5},
|
||||
{Base: model.Base{ID: 303}, PackageID: 1, FromDimensionValueID: 12, ToDimensionValueID: 22, RelationType: "contributes_to", Contribution: 5},
|
||||
}
|
||||
pkg.Adapters = []model.PackageAdapter{
|
||||
{PackageID: 1, SourceField: "input.tags", MatchValue: "呕吐", TargetDimensionValueID: 11, Weight: 8},
|
||||
{PackageID: 1, SourceField: "input.tags", MatchValue: "腹泻", TargetDimensionValueID: 12, Weight: 3},
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
func u64(value uint64) *uint64 { return &value }
|
||||
|
||||
func TestFuzzyWeightShowsConfirmationOnly(t *testing.T) {
|
||||
pkg := buildTestPackage()
|
||||
ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"腹泻"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
|
||||
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, ctx)
|
||||
if weights[12] != 3 {
|
||||
t.Fatalf("diarrhea weight = %d, want 3", weights[12])
|
||||
}
|
||||
stage, _ := pkg.StageByKey("symptom")
|
||||
view := pkg.BuildStageView(stage, weights, ctx, ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, nil)
|
||||
if len(view.Scripts) != 1 || view.Scripts[0].ScriptKey != "diarrhea_confirm" {
|
||||
t.Fatalf("fuzzy weight should only show the confirmation script, got %#v", view.Scripts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreeningRequiredWithMoreThanThreeCandidates(t *testing.T) {
|
||||
a, b, c, d := uint64(11), uint64(12), uint64(13), uint64(14)
|
||||
pkg := &Package{Package: model.ScriptPackage{Base: model.Base{ID: 1}, StartStageKey: "symptom"}}
|
||||
pkg.Dimensions = []model.PackageDimension{{Base: model.Base{ID: 1}, PackageID: 1, DimKey: "symptom", Name: "症状"}}
|
||||
pkg.Values = []model.DimensionValue{
|
||||
{Base: model.Base{ID: a}, PackageID: 1, DimensionID: 1, ValueKey: "a", Name: "A", InitialWeight: 3},
|
||||
{Base: model.Base{ID: b}, PackageID: 1, DimensionID: 1, ValueKey: "b", Name: "B", InitialWeight: 3},
|
||||
{Base: model.Base{ID: c}, PackageID: 1, DimensionID: 1, ValueKey: "c", Name: "C", InitialWeight: 3},
|
||||
{Base: model.Base{ID: d}, PackageID: 1, DimensionID: 1, ValueKey: "d", Name: "D", InitialWeight: 3},
|
||||
}
|
||||
pkg.Stages = []model.PackageStage{{Base: model.Base{ID: 31}, PackageID: 1, StageKey: "symptom", Name: "症状确认", PrimaryDimensionID: 1}}
|
||||
pkg.Scripts = []model.StageScript{
|
||||
{Base: model.Base{ID: 101}, PackageID: 1, StageID: 31, ScriptKey: "symptom_screen", Name: "症状筛选", ScriptType: "screen", Content: "有什么表现?", Multiple: true},
|
||||
{Base: model.Base{ID: 102}, PackageID: 1, StageID: 31, ScriptKey: "confirm_a", Name: "确认A", ScriptType: "confirm", Content: "有A吗?", DimensionValueID: &a, ShowThreshold: 5, ConfirmThreshold: 3},
|
||||
{Base: model.Base{ID: 103}, PackageID: 1, StageID: 31, ScriptKey: "choice_a", Name: "A细节", ScriptType: "choice", Content: "A多久了?", DimensionValueID: &a, ShowThreshold: 5, ConfirmThreshold: 3},
|
||||
}
|
||||
pkg.Options = []model.ScriptOption{
|
||||
{Base: model.Base{ID: 201}, PackageID: 1, ScriptID: 101, OptionKey: "a", Label: "A", TargetDimensionValueID: &a, Effect: "set", EffectValue: 10},
|
||||
{Base: model.Base{ID: 202}, PackageID: 1, ScriptID: 101, OptionKey: "b", Label: "B", TargetDimensionValueID: &b, Effect: "set", EffectValue: 10},
|
||||
{Base: model.Base{ID: 203}, PackageID: 1, ScriptID: 101, OptionKey: "c", Label: "C", TargetDimensionValueID: &c, Effect: "set", EffectValue: 10},
|
||||
{Base: model.Base{ID: 204}, PackageID: 1, ScriptID: 101, OptionKey: "d", Label: "D", TargetDimensionValueID: &d, Effect: "set", EffectValue: 10},
|
||||
{Base: model.Base{ID: 205}, PackageID: 1, ScriptID: 102, OptionKey: "yes", Label: "有", TargetDimensionValueID: &a, Effect: "set", EffectValue: 8},
|
||||
{Base: model.Base{ID: 206}, PackageID: 1, ScriptID: 102, OptionKey: "no", Label: "没有", TargetDimensionValueID: &a, Effect: "zero"},
|
||||
}
|
||||
stage, _ := pkg.StageByKey("symptom")
|
||||
ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
|
||||
emptyState := ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}
|
||||
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), emptyState, ctx)
|
||||
view := pkg.BuildStageView(stage, weights, ctx, emptyState, nil)
|
||||
if !view.ScreeningRequired {
|
||||
t.Fatalf("4 candidates should require the screening question, got %#v", view)
|
||||
}
|
||||
// 阶段视图带出完整题目集合:筛选题 + 每个候选的确认题与细节题,
|
||||
// 客户端本地作答、结束时一次性提交。
|
||||
keys := map[string]bool{}
|
||||
for _, script := range view.Scripts {
|
||||
keys[script.ScriptKey] = true
|
||||
}
|
||||
if !keys["symptom_screen"] || !keys["confirm_a"] || !keys["choice_a"] {
|
||||
t.Fatalf("stage view should carry the complete question set, got %v", keys)
|
||||
}
|
||||
// 筛选:只选 A、B -> A/B 确认(10),C/D 归零;筛选不再要求。
|
||||
state := ScriptState{ScriptAnswers: map[string][]string{"symptom_screen": {"a", "b"}}, DimensionSelects: map[string]bool{"a": true, "b": true, "c": false, "d": false}}
|
||||
weights = pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
|
||||
if weights[a] != 10 || weights[b] != 10 || weights[c] != 0 || weights[d] != 0 {
|
||||
t.Fatalf("screening weights = %v", weights)
|
||||
}
|
||||
view = pkg.BuildStageView(stage, weights, ctx, state, nil)
|
||||
if view.ScreeningRequired {
|
||||
t.Fatalf("screening should not be required after selection, got %#v", view)
|
||||
}
|
||||
for _, script := range view.Scripts {
|
||||
if script.ScriptKey == "confirm_a" {
|
||||
t.Fatalf("confirmed value should not carry its confirm question, got %#v", view.Scripts)
|
||||
}
|
||||
if script.ScriptKey == "choice_a" && script.Weight != 10 {
|
||||
t.Fatalf("selected value details should carry weight 10, got %#v", script)
|
||||
}
|
||||
}
|
||||
// 候选 <= 3 时不要求筛选。
|
||||
lessState := ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{"c": false, "d": false}}
|
||||
lessWeights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), lessState, ctx)
|
||||
lessView := pkg.BuildStageView(stage, lessWeights, ctx, lessState, nil)
|
||||
if lessView.ScreeningRequired {
|
||||
t.Fatalf("screening should not be required with <=3 candidates, got %#v", lessView)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyHintDoesNotPropagateDisease(t *testing.T) {
|
||||
pkg := buildTestPackage()
|
||||
ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"腹泻"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
|
||||
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, ctx)
|
||||
if weights[12] != 3 {
|
||||
t.Fatalf("diarrhea weight = %d, want 3", weights[12])
|
||||
}
|
||||
if weights[21] != 0 || weights[22] != 0 {
|
||||
t.Fatalf("fuzzy hint must not pull diseases in: %v", weights)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrongWeightShowsInfoScripts(t *testing.T) {
|
||||
pkg := buildTestPackage()
|
||||
ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"呕吐"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
|
||||
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, ctx)
|
||||
stage, _ := pkg.StageByKey("symptom")
|
||||
view := pkg.BuildStageView(stage, weights, ctx, ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, nil)
|
||||
if len(view.Scripts) != 1 || view.Scripts[0].ScriptKey != "vomiting_info" {
|
||||
t.Fatalf("strong weight should show the info script, got %#v", view.Scripts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllZeroShowsFallback(t *testing.T) {
|
||||
pkg := buildTestPackage()
|
||||
ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
|
||||
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, ctx)
|
||||
stage, _ := pkg.StageByKey("symptom")
|
||||
view := pkg.BuildStageView(stage, weights, ctx, ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}, nil)
|
||||
if len(view.Scripts) != 1 || view.Scripts[0].Type != "fallback" {
|
||||
t.Fatalf("all zero weights should show fallback, got %#v", view.Scripts)
|
||||
}
|
||||
if view.Fallback != "哪里不舒服?" {
|
||||
t.Fatalf("fallback = %q", view.Fallback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmYesSetsWeightAndPropagatesDisease(t *testing.T) {
|
||||
pkg := buildTestPackage()
|
||||
ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
|
||||
state := ScriptState{ScriptAnswers: map[string][]string{"vomiting_confirm": {"yes"}}, DimensionSelects: map[string]bool{}}
|
||||
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
|
||||
if weights[11] != 8 {
|
||||
t.Fatalf("vomiting weight = %d, want 8", weights[11])
|
||||
}
|
||||
if weights[21] != 5 {
|
||||
t.Fatalf("gastritis weight = %d, want 5 via linkage", weights[21])
|
||||
}
|
||||
// 候选疾病先带出确认题和确认后话术(完整题目集合,客户端本地作答后
|
||||
// 一次性提交;确认后话术在本地确认后再展示)。
|
||||
stage, _ := pkg.StageByKey("diagnosis")
|
||||
view := pkg.BuildStageView(stage, weights, ctx, state, nil)
|
||||
keys := map[string]bool{}
|
||||
for _, script := range view.Scripts {
|
||||
keys[script.ScriptKey] = true
|
||||
}
|
||||
if !keys["gastritis_confirm"] || !keys["gastritis_msg"] {
|
||||
t.Fatalf("candidate disease should carry confirm question and follow-up copy, got %v", keys)
|
||||
}
|
||||
// 确认疾病后(对,是这样 -> 权重10),确认题隐藏,只保留该疾病话术。
|
||||
state.ScriptAnswers["gastritis_confirm"] = []string{"yes"}
|
||||
weights = pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
|
||||
if weights[21] != 10 {
|
||||
t.Fatalf("confirmed gastritis weight = %d, want 10", weights[21])
|
||||
}
|
||||
view = pkg.BuildStageView(stage, weights, ctx, state, nil)
|
||||
if len(view.Scripts) != 1 || view.Scripts[0].ScriptKey != "gastritis_msg" {
|
||||
t.Fatalf("confirmed disease should keep only its copy, got %#v", view.Scripts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleDiseasesCanBeConfirmed(t *testing.T) {
|
||||
pkg := buildTestPackage()
|
||||
ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
|
||||
// 两个症状都确认(呕吐、腹泻),再确认两个疾病方向。
|
||||
state := ScriptState{ScriptAnswers: map[string][]string{
|
||||
"vomiting_confirm": {"yes"},
|
||||
"diarrhea_confirm": {"yes"},
|
||||
"gastritis_confirm": {"yes"},
|
||||
}, DimensionSelects: map[string]bool{"enteritis": true}}
|
||||
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
|
||||
if weights[21] != 10 {
|
||||
t.Fatalf("gastritis weight = %d, want 10 after confirmation", weights[21])
|
||||
}
|
||||
if weights[22] < 10 {
|
||||
t.Fatalf("enteritis weight = %d, want >= 10 after confirmation", weights[22])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmNoZeroesWeight(t *testing.T) {
|
||||
pkg := buildTestPackage()
|
||||
ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"腹泻"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
|
||||
state := ScriptState{ScriptAnswers: map[string][]string{"diarrhea_confirm": {"no"}}, DimensionSelects: map[string]bool{}}
|
||||
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
|
||||
if weights[12] != 0 {
|
||||
t.Fatalf("diarrhea weight = %d, want 0", weights[12])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectSelectSetsHighWeight(t *testing.T) {
|
||||
pkg := buildTestPackage()
|
||||
ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
|
||||
state := ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{"vomiting": true}}
|
||||
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
|
||||
if weights[11] != SelectWeight {
|
||||
t.Fatalf("selected weight = %d, want %d", weights[11], SelectWeight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecomputeIsDeterministic(t *testing.T) {
|
||||
pkg := buildTestPackage()
|
||||
ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"腹泻"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
|
||||
state := ScriptState{ScriptAnswers: map[string][]string{"diarrhea_confirm": {"yes"}}, DimensionSelects: map[string]bool{}}
|
||||
first := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
|
||||
for range 5 {
|
||||
again := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
|
||||
if !weightsEqual(first, again) {
|
||||
t.Fatalf("recompute is not deterministic: %v vs %v", first, again)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTemplate(t *testing.T) {
|
||||
ctx := RenderContext{
|
||||
Input: map[string]interface{}{"customer_name": "王女士", "products": []interface{}{"商品A", "商品B"}},
|
||||
Derived: map[string]interface{}{},
|
||||
Form: map[string]interface{}{"note": "已接受"},
|
||||
}
|
||||
got := RenderTemplate("您好{{input.customer_name}},看到{{input.products}},备注{{form.note}}。", ctx)
|
||||
if got != "您好王女士,看到商品A、商品B,备注已接受。" {
|
||||
t.Fatalf("RenderTemplate = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosisRequiresConfirmedDiseaseBeforeAdvancing(t *testing.T) {
|
||||
pkg := buildTestPackage()
|
||||
ctx := RenderContext{Input: map[string]interface{}{}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
|
||||
// 症状确认带出候选疾病(权重5),但还没有确认任何疾病方向。
|
||||
state := ScriptState{ScriptAnswers: map[string][]string{"vomiting_confirm": {"yes"}}, DimensionSelects: map[string]bool{}}
|
||||
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
|
||||
stage, _ := pkg.StageByKey("diagnosis")
|
||||
view := pkg.BuildStageView(stage, weights, ctx, state, nil)
|
||||
if view.CanNext {
|
||||
t.Fatalf("diagnosis should require a confirmed disease before advancing, got can_next=%v", view.CanNext)
|
||||
}
|
||||
if view.CanNextReason == "" {
|
||||
t.Fatalf("diagnosis block should carry a reason, got %q", view.CanNextReason)
|
||||
}
|
||||
// 确认疾病后(权重10)才能进入药品推荐。
|
||||
state.ScriptAnswers["gastritis_confirm"] = []string{"yes"}
|
||||
weights = pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
|
||||
view = pkg.BuildStageView(stage, weights, ctx, state, nil)
|
||||
if !view.CanNext {
|
||||
t.Fatalf("confirmed disease should allow advancing, got can_next=%v reason=%q", view.CanNext, view.CanNextReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageViewOnlyShowsPrimaryDimension(t *testing.T) {
|
||||
pkg := buildTestPackage()
|
||||
ctx := RenderContext{Input: map[string]interface{}{"tags": []interface{}{"呕吐"}}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
|
||||
state := ScriptState{ScriptAnswers: map[string][]string{"vomiting_confirm": {"yes"}}, DimensionSelects: map[string]bool{}}
|
||||
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
|
||||
// 症状阶段:症状与疾病(联动权重5)都有值,但只显示主维度症状。
|
||||
stage, _ := pkg.StageByKey("symptom")
|
||||
view := pkg.BuildStageView(stage, weights, ctx, state, nil)
|
||||
if len(view.Dimensions) != 1 || view.Dimensions[0].DimKey != "symptom" {
|
||||
t.Fatalf("symptom stage should only show the symptom dimension, got %#v", view.Dimensions)
|
||||
}
|
||||
// 拟诊阶段:只显示疾病维度,不显示症状。
|
||||
diagStage, _ := pkg.StageByKey("diagnosis")
|
||||
diagView := pkg.BuildStageView(diagStage, weights, ctx, state, nil)
|
||||
if len(diagView.Dimensions) != 1 || diagView.Dimensions[0].DimKey != "disease" {
|
||||
t.Fatalf("diagnosis stage should only show the disease dimension, got %#v", diagView.Dimensions)
|
||||
}
|
||||
// 没有主维度的阶段(开场)不显示任何维度。
|
||||
openStage := model.PackageStage{Base: model.Base{ID: 33}, PackageID: 1, StageKey: "opening", Name: "开场", PrimaryDimensionID: 0}
|
||||
openView := pkg.BuildStageView(openStage, weights, ctx, state, nil)
|
||||
if len(openView.Dimensions) != 0 {
|
||||
t.Fatalf("opening stage should show no dimensions, got %#v", openView.Dimensions)
|
||||
}
|
||||
}
|
||||
322
internal/scriptkit/handler.go
Normal file
322
internal/scriptkit/handler.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package scriptkit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Handler exposes the script package admin API.
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHandler(db *gorm.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
}
|
||||
|
||||
// PackageInput is the replaceable document of a script package.
|
||||
type PackageInput struct {
|
||||
Name string `json:"name"`
|
||||
StartStageKey string `json:"start_stage_key"`
|
||||
Dimensions []DimensionInput `json:"dimensions"`
|
||||
Stages []StageInput `json:"stages"`
|
||||
Linkages []LinkageInput `json:"linkages"`
|
||||
Adapters []AdapterInput `json:"adapters"`
|
||||
}
|
||||
|
||||
// DimensionInput is one dimension with its values.
|
||||
type DimensionInput struct {
|
||||
DimKey string `json:"dim_key"`
|
||||
Name string `json:"name"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Values []ValueInput `json:"values"`
|
||||
}
|
||||
|
||||
// ValueInput is one dimension value.
|
||||
type ValueInput struct {
|
||||
ValueKey string `json:"value_key"`
|
||||
Name string `json:"name"`
|
||||
InitialWeight int `json:"initial_weight"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// StageInput is one package stage with its scripts.
|
||||
type StageInput struct {
|
||||
StageKey string `json:"stage_key"`
|
||||
Name string `json:"name"`
|
||||
Purpose string `json:"purpose"`
|
||||
PrimaryDimensionKey string `json:"primary_dimension_key"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Scripts []ScriptInput `json:"scripts"`
|
||||
}
|
||||
|
||||
// ScriptInput is one stage script.
|
||||
type ScriptInput struct {
|
||||
ScriptKey string `json:"script_key"`
|
||||
Name string `json:"name"`
|
||||
ScriptType string `json:"script_type"`
|
||||
Content string `json:"content"`
|
||||
DimensionValueKey string `json:"dimension_value_key"`
|
||||
ShowThreshold int `json:"show_threshold"`
|
||||
ConfirmThreshold int `json:"confirm_threshold"`
|
||||
CollectFieldKey string `json:"collect_field_key"`
|
||||
Required bool `json:"required"`
|
||||
Multiple bool `json:"multiple"`
|
||||
Products []ProductInput `json:"products"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Options []OptionInput `json:"options"`
|
||||
}
|
||||
|
||||
// ProductInput is one static product attached to a script.
|
||||
type ProductInput struct {
|
||||
SKUCode string `json:"sku_code"`
|
||||
ProductName string `json:"product_name"`
|
||||
}
|
||||
|
||||
// OptionInput is one answer option of a script.
|
||||
type OptionInput struct {
|
||||
OptionKey string `json:"option_key"`
|
||||
Label string `json:"label"`
|
||||
TargetDimensionValueKey string `json:"target_dimension_value_key"`
|
||||
Effect string `json:"effect"`
|
||||
EffectValue int `json:"effect_value"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// LinkageInput is one dimension linkage.
|
||||
type LinkageInput struct {
|
||||
FromDimensionValueKey string `json:"from_dimension_value_key"`
|
||||
ToDimensionValueKey string `json:"to_dimension_value_key"`
|
||||
RelationType string `json:"relation_type"`
|
||||
Contribution int `json:"contribution"`
|
||||
ActivationThreshold int `json:"activation_threshold"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// AdapterInput is one entry weight adapter.
|
||||
type AdapterInput struct {
|
||||
SourceField string `json:"source_field"`
|
||||
MatchValue string `json:"match_value"`
|
||||
TargetDimensionValueKey string `json:"target_dimension_value_key"`
|
||||
Weight int `json:"weight"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
func scenarioPackageID(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ID", "场景 ID 不正确")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// Get returns the script package of a scenario as a nested JSON document.
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
scenarioID, ok := scenarioPackageID(c)
|
||||
if !ok || !access.CanViewScenario(h.db, p, scenarioID) {
|
||||
if ok {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
|
||||
}
|
||||
return
|
||||
}
|
||||
pkg, err := LoadPackageByScenario(h.db, p.TenantID, scenarioID)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "话术包不存在")
|
||||
return
|
||||
}
|
||||
response.OK(c, PackageDocument(pkg))
|
||||
}
|
||||
|
||||
// Replace replaces the whole script package of a scenario in one transaction.
|
||||
func (h *Handler) Replace(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
scenarioID, ok := scenarioPackageID(c)
|
||||
if !ok || !access.CanEditScenario(h.db, p, scenarioID) {
|
||||
if ok {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在或不可编辑")
|
||||
}
|
||||
return
|
||||
}
|
||||
var input PackageInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术包 JSON 格式不正确")
|
||||
return
|
||||
}
|
||||
if err := ValidatePackageInput(input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", err.Error())
|
||||
return
|
||||
}
|
||||
if err := SavePackage(h.db, p.TenantID, p.UserID, scenarioID, input); err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术包失败")
|
||||
return
|
||||
}
|
||||
h.Get(c)
|
||||
}
|
||||
|
||||
// SavePackage replaces the script package of a scenario in one transaction.
|
||||
func SavePackage(db *gorm.DB, tenantID, userID, scenarioID uint64, input PackageInput) error {
|
||||
if err := ValidatePackageInput(input); err != nil {
|
||||
return err
|
||||
}
|
||||
var old model.ScriptPackage
|
||||
hadOld := true
|
||||
if err := db.Where("scenario_id = ? AND tenant_id = ?", scenarioID, tenantID).First(&old).Error; err != nil {
|
||||
hadOld = false
|
||||
}
|
||||
principal := auth.Principal{TenantID: tenantID, UserID: userID}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if hadOld {
|
||||
for _, target := range []interface{}{
|
||||
&model.ScriptOption{}, &model.StageScript{}, &model.DimensionLinkage{},
|
||||
&model.PackageAdapter{}, &model.PackageStage{}, &model.DimensionValue{}, &model.PackageDimension{},
|
||||
} {
|
||||
if err := tx.Where("tenant_id = ? AND package_id = ?", tenantID, old.ID).Delete(target).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
pkg := model.ScriptPackage{TenantID: tenantID, ScenarioID: scenarioID, Name: input.Name, Status: "active", StartStageKey: input.StartStageKey, CreatedBy: userID}
|
||||
if hadOld {
|
||||
pkg.ID = old.ID
|
||||
pkg.CreatedAt = old.CreatedAt
|
||||
}
|
||||
if err := tx.Save(&pkg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
dimensionIDs := map[string]uint64{}
|
||||
for _, dimension := range input.Dimensions {
|
||||
row := model.PackageDimension{TenantID: tenantID, PackageID: pkg.ID, DimKey: dimension.DimKey, Name: dimension.Name, SortOrder: dimension.SortOrder}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
dimensionIDs[dimension.DimKey] = row.ID
|
||||
}
|
||||
valueIDs := map[string]uint64{}
|
||||
for _, dimension := range input.Dimensions {
|
||||
for _, value := range dimension.Values {
|
||||
row := model.DimensionValue{TenantID: tenantID, PackageID: pkg.ID, DimensionID: dimensionIDs[dimension.DimKey], ValueKey: value.ValueKey, Name: value.Name, InitialWeight: value.InitialWeight, SortOrder: value.SortOrder}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
valueIDs[value.ValueKey] = row.ID
|
||||
}
|
||||
}
|
||||
for _, stage := range input.Stages {
|
||||
row := model.PackageStage{TenantID: tenantID, PackageID: pkg.ID, StageKey: stage.StageKey, Name: stage.Name, Purpose: stage.Purpose, PrimaryDimensionID: dimensionIDs[stage.PrimaryDimensionKey], SortOrder: stage.SortOrder}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, script := range stage.Scripts {
|
||||
var dimensionValueID *uint64
|
||||
if script.DimensionValueKey != "" {
|
||||
id := valueIDs[script.DimensionValueKey]
|
||||
dimensionValueID = &id
|
||||
}
|
||||
products, _ := json.Marshal(script.Products)
|
||||
if script.Products == nil {
|
||||
products = []byte(`[]`)
|
||||
}
|
||||
scriptRow := model.StageScript{TenantID: tenantID, PackageID: pkg.ID, StageID: row.ID, ScriptKey: script.ScriptKey, Name: script.Name, ScriptType: script.ScriptType, Content: script.Content, DimensionValueID: dimensionValueID, ShowThreshold: script.ShowThreshold, ConfirmThreshold: script.ConfirmThreshold, CollectFieldKey: script.CollectFieldKey, Required: script.Required, Multiple: script.Multiple, Products: datatypes.JSON(products), SortOrder: script.SortOrder}
|
||||
if err := tx.Create(&scriptRow).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, option := range script.Options {
|
||||
var targetID *uint64
|
||||
if option.TargetDimensionValueKey != "" {
|
||||
id := valueIDs[option.TargetDimensionValueKey]
|
||||
targetID = &id
|
||||
}
|
||||
optionRow := model.ScriptOption{TenantID: tenantID, PackageID: pkg.ID, ScriptID: scriptRow.ID, OptionKey: option.OptionKey, Label: option.Label, TargetDimensionValueID: targetID, Effect: option.Effect, EffectValue: option.EffectValue, SortOrder: option.SortOrder}
|
||||
if err := tx.Create(&optionRow).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, linkage := range input.Linkages {
|
||||
row := model.DimensionLinkage{TenantID: tenantID, PackageID: pkg.ID, FromDimensionValueID: valueIDs[linkage.FromDimensionValueKey], ToDimensionValueID: valueIDs[linkage.ToDimensionValueKey], RelationType: linkage.RelationType, Contribution: linkage.Contribution, ActivationThreshold: linkage.ActivationThreshold, Condition: datatypes.JSON([]byte(`{}`)), SortOrder: linkage.SortOrder}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, adapter := range input.Adapters {
|
||||
row := model.PackageAdapter{TenantID: tenantID, PackageID: pkg.ID, SourceField: adapter.SourceField, MatchValue: adapter.MatchValue, TargetDimensionValueID: valueIDs[adapter.TargetDimensionValueKey], Weight: adapter.Weight, SortOrder: adapter.SortOrder}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return audit.RecordTx(tx, principal, "replace", "script_package", pkg.ID, gin.H{"scenario_id": scenarioID})
|
||||
})
|
||||
}
|
||||
|
||||
// PackageDocument renders a loaded package as a nested JSON document.
|
||||
func PackageDocument(pkg *Package) gin.H {
|
||||
valuesByDimension := map[uint64][]ValueInput{}
|
||||
valueKeys := map[uint64]string{}
|
||||
dimensionKeys := map[uint64]string{}
|
||||
for _, value := range pkg.Values {
|
||||
valueKeys[value.ID] = value.ValueKey
|
||||
valuesByDimension[value.DimensionID] = append(valuesByDimension[value.DimensionID], ValueInput{ValueKey: value.ValueKey, Name: value.Name, InitialWeight: value.InitialWeight, SortOrder: value.SortOrder})
|
||||
}
|
||||
for _, dimension := range pkg.Dimensions {
|
||||
dimensionKeys[dimension.ID] = dimension.DimKey
|
||||
}
|
||||
dimensions := make([]DimensionInput, 0, len(pkg.Dimensions))
|
||||
for _, dimension := range pkg.Dimensions {
|
||||
dimensions = append(dimensions, DimensionInput{DimKey: dimension.DimKey, Name: dimension.Name, SortOrder: dimension.SortOrder, Values: valuesByDimension[dimension.ID]})
|
||||
}
|
||||
stages := make([]StageInput, 0, len(pkg.Stages))
|
||||
for _, stage := range pkg.Stages {
|
||||
item := StageInput{StageKey: stage.StageKey, Name: stage.Name, Purpose: stage.Purpose, PrimaryDimensionKey: dimensionKeys[stage.PrimaryDimensionID], SortOrder: stage.SortOrder, Scripts: []ScriptInput{}}
|
||||
for _, script := range pkg.Scripts {
|
||||
if script.StageID != stage.ID {
|
||||
continue
|
||||
}
|
||||
scriptItem := ScriptInput{ScriptKey: script.ScriptKey, Name: script.Name, ScriptType: script.ScriptType, Content: script.Content, DimensionValueKey: valueKeys[derefID(script.DimensionValueID)], ShowThreshold: script.ShowThreshold, ConfirmThreshold: script.ConfirmThreshold, CollectFieldKey: script.CollectFieldKey, Required: script.Required, Multiple: script.Multiple, Products: []ProductInput{}, SortOrder: script.SortOrder, Options: []OptionInput{}}
|
||||
var products []ProductInput
|
||||
if err := json.Unmarshal(script.Products, &products); err == nil && products != nil {
|
||||
scriptItem.Products = products
|
||||
}
|
||||
for _, option := range pkg.Options {
|
||||
if option.ScriptID != script.ID {
|
||||
continue
|
||||
}
|
||||
scriptItem.Options = append(scriptItem.Options, OptionInput{OptionKey: option.OptionKey, Label: option.Label, TargetDimensionValueKey: valueKeys[derefID(option.TargetDimensionValueID)], Effect: option.Effect, EffectValue: option.EffectValue, SortOrder: option.SortOrder})
|
||||
}
|
||||
item.Scripts = append(item.Scripts, scriptItem)
|
||||
}
|
||||
stages = append(stages, item)
|
||||
}
|
||||
linkages := make([]LinkageInput, 0, len(pkg.Linkages))
|
||||
for _, linkage := range pkg.Linkages {
|
||||
linkages = append(linkages, LinkageInput{FromDimensionValueKey: valueKeys[linkage.FromDimensionValueID], ToDimensionValueKey: valueKeys[linkage.ToDimensionValueID], RelationType: linkage.RelationType, Contribution: linkage.Contribution, ActivationThreshold: linkage.ActivationThreshold, SortOrder: linkage.SortOrder})
|
||||
}
|
||||
adapters := make([]AdapterInput, 0, len(pkg.Adapters))
|
||||
for _, adapter := range pkg.Adapters {
|
||||
adapters = append(adapters, AdapterInput{SourceField: adapter.SourceField, MatchValue: adapter.MatchValue, TargetDimensionValueKey: valueKeys[adapter.TargetDimensionValueID], Weight: adapter.Weight, SortOrder: adapter.SortOrder})
|
||||
}
|
||||
return gin.H{
|
||||
"id": pkg.Package.ID, "name": pkg.Package.Name, "status": pkg.Package.Status,
|
||||
"start_stage_key": pkg.Package.StartStageKey,
|
||||
"dimensions": dimensions, "stages": stages, "linkages": linkages, "adapters": adapters,
|
||||
}
|
||||
}
|
||||
|
||||
func derefID(value *uint64) uint64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
136
internal/scriptkit/validator.go
Normal file
136
internal/scriptkit/validator.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package scriptkit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var keyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`)
|
||||
|
||||
var allowedScriptTypes = map[string]bool{
|
||||
"confirm": true, "info": true, "choice": true, "screen": true, "template": true, "fallback": true, "message": true,
|
||||
}
|
||||
|
||||
var allowedEffects = map[string]bool{
|
||||
"set": true, "add": true, "subtract": true, "zero": true,
|
||||
}
|
||||
|
||||
var allowedRelationTypes = map[string]bool{
|
||||
"contributes_to": true, "requires": true, "excludes": true,
|
||||
}
|
||||
|
||||
// ValidatePackageInput checks a package document before saving.
|
||||
func ValidatePackageInput(input PackageInput) error {
|
||||
if input.Name == "" {
|
||||
return fmt.Errorf("话术包缺少名称")
|
||||
}
|
||||
if !keyPattern.MatchString(input.StartStageKey) {
|
||||
return fmt.Errorf("开始阶段标识格式不正确")
|
||||
}
|
||||
dimensionKeys := map[string]bool{}
|
||||
valueKeys := map[string]bool{}
|
||||
stageKeys := map[string]bool{}
|
||||
scriptKeys := map[string]bool{}
|
||||
valueDimension := map[string]string{}
|
||||
for _, dimension := range input.Dimensions {
|
||||
if !keyPattern.MatchString(dimension.DimKey) || dimensionKeys[dimension.DimKey] {
|
||||
return fmt.Errorf("维度标识必须唯一且格式正确")
|
||||
}
|
||||
if dimension.Name == "" {
|
||||
return fmt.Errorf("维度 %s 缺少名称", dimension.DimKey)
|
||||
}
|
||||
dimensionKeys[dimension.DimKey] = true
|
||||
for _, value := range dimension.Values {
|
||||
if !keyPattern.MatchString(value.ValueKey) || valueKeys[value.ValueKey] {
|
||||
return fmt.Errorf("维度值标识必须唯一且格式正确")
|
||||
}
|
||||
if value.Name == "" {
|
||||
return fmt.Errorf("维度值 %s 缺少名称", value.ValueKey)
|
||||
}
|
||||
valueKeys[value.ValueKey] = true
|
||||
valueDimension[value.ValueKey] = dimension.DimKey
|
||||
}
|
||||
}
|
||||
for _, stage := range input.Stages {
|
||||
if !keyPattern.MatchString(stage.StageKey) || stageKeys[stage.StageKey] {
|
||||
return fmt.Errorf("阶段标识必须唯一且格式正确")
|
||||
}
|
||||
if stage.Name == "" {
|
||||
return fmt.Errorf("阶段 %s 缺少名称", stage.StageKey)
|
||||
}
|
||||
if stage.PrimaryDimensionKey != "" && !dimensionKeys[stage.PrimaryDimensionKey] {
|
||||
return fmt.Errorf("阶段 %s 的主维度不存在", stage.StageKey)
|
||||
}
|
||||
stageKeys[stage.StageKey] = true
|
||||
for _, script := range stage.Scripts {
|
||||
if !keyPattern.MatchString(script.ScriptKey) || scriptKeys[script.ScriptKey] {
|
||||
return fmt.Errorf("话术标识必须唯一且格式正确")
|
||||
}
|
||||
if script.Name == "" {
|
||||
return fmt.Errorf("话术 %s 缺少名称", script.ScriptKey)
|
||||
}
|
||||
if !allowedScriptTypes[script.ScriptType] {
|
||||
return fmt.Errorf("话术 %s 的类型 %s 不支持", script.ScriptKey, script.ScriptType)
|
||||
}
|
||||
if script.DimensionValueKey != "" && !valueKeys[script.DimensionValueKey] {
|
||||
return fmt.Errorf("话术 %s 关联的维度值不存在", script.ScriptKey)
|
||||
}
|
||||
if script.ScriptType == "confirm" && script.DimensionValueKey == "" {
|
||||
return fmt.Errorf("确认性话术 %s 必须关联维度值", script.ScriptKey)
|
||||
}
|
||||
if script.ShowThreshold < 0 || script.ConfirmThreshold < 0 {
|
||||
return fmt.Errorf("话术 %s 的阈值不能为负数", script.ScriptKey)
|
||||
}
|
||||
if script.ConfirmThreshold > script.ShowThreshold && script.ShowThreshold > 0 {
|
||||
return fmt.Errorf("话术 %s 的确认阈值不能大于显示阈值", script.ScriptKey)
|
||||
}
|
||||
scriptKeys[script.ScriptKey] = true
|
||||
optionKeys := map[string]bool{}
|
||||
for _, option := range script.Options {
|
||||
if !keyPattern.MatchString(option.OptionKey) || optionKeys[option.OptionKey] {
|
||||
return fmt.Errorf("话术 %s 的选项标识必须唯一且格式正确", script.ScriptKey)
|
||||
}
|
||||
if option.Label == "" {
|
||||
return fmt.Errorf("话术 %s 的选项 %s 缺少文案", script.ScriptKey, option.OptionKey)
|
||||
}
|
||||
if option.TargetDimensionValueKey != "" && !valueKeys[option.TargetDimensionValueKey] {
|
||||
return fmt.Errorf("话术 %s 选项 %s 关联的维度值不存在", script.ScriptKey, option.OptionKey)
|
||||
}
|
||||
if !allowedEffects[option.Effect] {
|
||||
return fmt.Errorf("话术 %s 选项 %s 的效果 %s 不支持", script.ScriptKey, option.OptionKey, option.Effect)
|
||||
}
|
||||
if option.EffectValue < 0 {
|
||||
return fmt.Errorf("话术 %s 选项 %s 的效果值不能为负数", script.ScriptKey, option.OptionKey)
|
||||
}
|
||||
optionKeys[option.OptionKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !stageKeys[input.StartStageKey] {
|
||||
return fmt.Errorf("开始阶段 %s 不存在", input.StartStageKey)
|
||||
}
|
||||
for _, linkage := range input.Linkages {
|
||||
if !valueKeys[linkage.FromDimensionValueKey] || !valueKeys[linkage.ToDimensionValueKey] {
|
||||
return fmt.Errorf("维度联动引用了不存在的维度值")
|
||||
}
|
||||
if !allowedRelationTypes[linkage.RelationType] {
|
||||
return fmt.Errorf("联动关系 %s 不支持", linkage.RelationType)
|
||||
}
|
||||
if linkage.Contribution <= 0 {
|
||||
return fmt.Errorf("联动贡献值必须为正数")
|
||||
}
|
||||
}
|
||||
for _, adapter := range input.Adapters {
|
||||
if adapter.SourceField == "" || adapter.MatchValue == "" {
|
||||
return fmt.Errorf("入场适配规则缺少来源字段或匹配值")
|
||||
}
|
||||
if !valueKeys[adapter.TargetDimensionValueKey] {
|
||||
return fmt.Errorf("入场适配规则 %s=%s 关联的维度值不存在", adapter.SourceField, adapter.MatchValue)
|
||||
}
|
||||
if adapter.Weight <= 0 {
|
||||
return fmt.Errorf("入场适配规则 %s=%s 的权重必须为正数", adapter.SourceField, adapter.MatchValue)
|
||||
}
|
||||
}
|
||||
_ = valueDimension
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user