// 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 } }