This commit is contained in:
Eric 1549169735@qq.com
2026-09-13 20:08:13 +08:00
parent 7cc06976ab
commit da3f16e6db
65 changed files with 3855 additions and 2153 deletions

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