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

@@ -1,7 +1,6 @@
package run
import (
"encoding/json"
"net/http"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
@@ -20,11 +19,10 @@ type DetailHeader struct {
type DetailEvent struct {
model.SOPRunEvent
NodeTitle string `json:"node_title"`
NodeType string `json:"node_type"`
NodeContent string `json:"node_content"`
NodeConfig datatypes.JSON `json:"-"`
Outputs []KnowledgeGroup `json:"outputs,omitempty" gorm:"-"`
NodeTitle string `json:"node_title"`
NodeType string `json:"node_type"`
NodeContent string `json:"node_content"`
NodeConfig datatypes.JSON `json:"-"`
}
type DetailFeedback struct {
@@ -51,33 +49,15 @@ func (h *Handler) Detail(c *gin.Context) {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行事件失败")
return
}
answers := map[string]interface{}{}
_ = json.Unmarshal(header.Answers, &answers)
derived := map[string]interface{}{}
_ = json.Unmarshal(header.Derived, &derived)
input := map[string]interface{}{}
_ = json.Unmarshal(header.Input, &input)
context := runtimeContext(input, derived, answers)
context["__knowledge_snapshot"] = json.RawMessage(header.KnowledgeSnapshot)
for i := range events {
if events[i].NodeType != "knowledge" {
continue
}
var config knowledgeNodeConfig
_ = json.Unmarshal(events[i].NodeConfig, &config)
if config.KnowledgeSelector == nil {
continue
}
var node model.SOPNode
if err := h.db.Where("sop_id = ? AND node_key = ?", header.SOPID, events[i].NodeKey).First(&node).Error; err != nil {
continue
}
outputs, loadErr := loadKnowledgeOutputs(h.db, node, principal.TenantID, context, *config.KnowledgeSelector)
if loadErr != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录知识快照不可用")
return
}
events[i].Outputs = outputs
dimensions := make([]model.RunDimension, 0)
if err := h.db.Where("run_id = ? AND tenant_id = ?", id, principal.TenantID).Order("id").Find(&dimensions).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询维度状态失败")
return
}
scriptFeedback := make([]model.ScriptFeedback, 0)
if err := h.db.Where("run_id = ? AND tenant_id = ?", id, principal.TenantID).Order("created_at").Find(&scriptFeedback).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询话术反馈失败")
return
}
feedback := make([]DetailFeedback, 0)
if err := h.db.Table("sop_feedback f").Select("f.*, u.display_name AS user_name").Joins("JOIN users u ON u.id = f.user_id").Where("f.run_id = ? AND f.tenant_id = ?", id, principal.TenantID).Order("f.created_at").Scan(&feedback).Error; err != nil {
@@ -92,5 +72,5 @@ func (h *Handler) Detail(c *gin.Context) {
if header.Answers == nil {
header.Answers = datatypes.JSON([]byte(`{}`))
}
response.OK(c, gin.H{"run": header, "events": events, "feedback": feedback, "fields": fields})
response.OK(c, gin.H{"run": header, "events": events, "feedback": feedback, "fields": fields, "dimensions": dimensions, "script_feedback": scriptFeedback})
}

View File

@@ -14,6 +14,7 @@ import (
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"git.iwork-ai.com/xdc/iqudo-top1/internal/resultcontract"
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/datatypes"
@@ -125,20 +126,21 @@ func (h *Handler) Start(c *gin.Context) {
return
}
derivedRaw, _ := json.Marshal(derived)
knowledgeRaw, err := snapshotKnowledge(h.db, p.TenantID, sopItem.ScenarioID)
if err != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_SNAPSHOT_FAILED", "生成知识快照失败")
return
}
externalRef := input.ExternalRef
if externalRef == "" {
externalRef = "run-" + uuid.NewString()
}
run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, OperatorID: p.UserID, ExternalRef: externalRef, CurrentNodeKey: sop.StartNodeKey, Status: "running", Answers: datatypes.JSON(initialAnswers), Input: datatypes.JSON(initialAnswers), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), Result: "", StartedAt: time.Now()}
scriptStateRaw, _ := json.Marshal(scriptkit.ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}})
run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, OperatorID: p.UserID, ExternalRef: externalRef, CurrentNodeKey: sop.StartNodeKey, Status: "running", Answers: datatypes.JSON(initialAnswers), Input: datatypes.JSON(initialAnswers), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), ScriptState: datatypes.JSON(scriptStateRaw), Result: "", StartedAt: time.Now()}
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&run).Error; err != nil {
return err
}
if pkg, loadErr := loadRunPackage(tx, run); loadErr == nil {
if persistErr := persistRunWeights(tx, run, pkg, runWeights(run, pkg)); persistErr != nil {
return persistErr
}
}
payload, err := json.Marshal(gin.H{"source": "scenario_input", "mapped_field_keys": sortedKeys(normalizedInput), "matched_rule_keys": matchedRules})
if err != nil {
return err
@@ -235,16 +237,27 @@ func (h *Handler) Options(c *gin.Context) {
response.OK(c, gin.H{"sops": sops, "operators": operators})
}
// answerInput accepts both legacy node answers and stage script answers.
type answerInput struct {
NodeKey string `json:"node_key"`
Answers map[string]interface{} `json:"answers"`
// stage-specific fields
ScriptKey string `json:"script_key"`
OptionKeys []string `json:"option_keys"`
Value string `json:"value"`
ScriptAnswers []stageScriptAnswerInput `json:"script_answers"`
DimensionSelects map[string]bool `json:"dimension_selects"`
DimensionValueKey string `json:"dimension_value_key"`
Selected *bool `json:"selected"`
}
func (h *Handler) Answer(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var input struct {
NodeKey string `json:"node_key"`
Answers map[string]interface{} `json:"answers"`
}
var input answerInput
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "回答格式不正确")
return
@@ -267,6 +280,9 @@ func (h *Handler) Answer(c *gin.Context) {
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPID, updated.CurrentNodeKey, p.TenantID).First(&currentNode).Error; err != nil {
return err
}
if currentNode.Type == "stage" {
return applyStageAnswer(tx, &updated, currentNode, stageAnswerInput{NodeKey: input.NodeKey, ScriptKey: input.ScriptKey, OptionKeys: input.OptionKeys, Value: input.Value, ScriptAnswers: input.ScriptAnswers, DimensionSelects: input.DimensionSelects, Answers: input.Answers, DimensionValueKey: input.DimensionValueKey, Selected: input.Selected})
}
fields := make([]model.ScenarioField, 0)
if err := tx.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", updated.SOPID, p.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
return err
@@ -281,53 +297,16 @@ func (h *Handler) Answer(c *gin.Context) {
for key, value := range input.Answers {
answers[key] = value
}
if err := validateKnowledgeSelections(currentNode, updated, answers); err != nil {
return err
}
var edges []model.SOPEdge
if err := tx.Where("sop_id = ? AND source_node_key = ? AND tenant_id = ?", updated.SOPID, updated.CurrentNodeKey, p.TenantID).Order("priority, id").Find(&edges).Error; err != nil {
return err
}
sortEdges(edges)
nextKey := ""
for _, edge := range edges {
matched, matchErr := matchCondition(json.RawMessage(edge.Condition), answers)
if matchErr != nil {
return matchErr
}
if matched {
nextKey = edge.TargetNodeKey
break
}
}
if nextKey == "" {
return errors.New("没有满足条件的下一节点")
}
answerBytes, _ := json.Marshal(answers)
payload, _ := json.Marshal(input)
if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: updated.CurrentNodeKey, Action: "answer", Payload: datatypes.JSON(payload)}).Error; err != nil {
return err
}
var next model.SOPNode
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPID, nextKey, p.TenantID).First(&next).Error; err != nil {
return err
}
updates := map[string]interface{}{"current_node_key": nextKey, "answers": datatypes.JSON(answerBytes)}
if next.Type == "finish" || next.Type == "escalate" {
now := time.Now()
updates["status"] = "completed"
updates["completed_at"] = &now
updates["result"] = next.Type
updated.Status = "completed"
updated.CompletedAt = &now
updated.Result = next.Type
}
if err := tx.Model(&updated).Updates(updates).Error; err != nil {
return err
}
updated.CurrentNodeKey = nextKey
updated.Answers = datatypes.JSON(answerBytes)
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: nextKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error
if err := tx.Model(&updated).Update("answers", updated.Answers).Error; err != nil {
return err
}
return advanceRunNode(tx, &updated)
})
if err != nil {
response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", err.Error())
@@ -336,6 +315,127 @@ func (h *Handler) Answer(c *gin.Context) {
h.respondRun(c, updated)
}
// Next advances a stage run to the next node after checking stage completion.
func (h *Handler) Next(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var updated model.SOPRun
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&updated).Error; err != nil {
return err
}
if updated.Status != "running" {
return errors.New("run is not active")
}
if !canOperateRun(p, updated) {
return gorm.ErrRecordNotFound
}
var currentNode model.SOPNode
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", updated.SOPID, updated.CurrentNodeKey, p.TenantID).First(&currentNode).Error; err != nil {
return err
}
if currentNode.Type == "stage" {
if err := advanceFromStage(tx, &updated, currentNode); err != nil {
return err
}
} else if currentNode.Type == "question" || currentNode.Type == "choice" || currentNode.Type == "form" {
return errors.New("当前节点需要先提交表单")
} else {
if err := advanceRunNode(tx, &updated); err != nil {
return err
}
}
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: updated.CurrentNodeKey, Action: "next", Payload: datatypes.JSON([]byte(`{}`))}).Error
})
if err != nil {
response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", err.Error())
return
}
h.respondRun(c, updated)
}
// Back moves the run to the previous node.
func (h *Handler) Back(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var updated model.SOPRun
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&updated).Error; err != nil {
return err
}
if updated.Status != "running" {
return errors.New("run is not active")
}
if !canOperateRun(p, updated) {
return gorm.ErrRecordNotFound
}
if err := backRunNode(tx, &updated); err != nil {
return err
}
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: updated.ID, NodeKey: updated.CurrentNodeKey, Action: "back", Payload: datatypes.JSON([]byte(`{}`))}).Error
})
if err != nil {
response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", err.Error())
return
}
h.respondRun(c, updated)
}
// ScriptFeedback records like/report/unreasonable feedback on a script.
func (h *Handler) ScriptFeedback(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var input struct {
NodeKey string `json:"node_key"`
ScriptKey string `json:"script_key" binding:"required"`
FeedbackType string `json:"feedback_type" binding:"required"`
Note string `json:"note" binding:"max=2000"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术反馈格式不正确")
return
}
if input.FeedbackType != "like" && input.FeedbackType != "report" && input.FeedbackType != "unreasonable" {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术反馈类型不正确")
return
}
var run model.SOPRun
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&run).Error; err != nil || !canViewRun(p, run) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
pkg, err := loadRunPackage(h.db, run)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景没有配置话术包")
return
}
script, ok := pkg.ScriptByKey(input.ScriptKey)
if !ok {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "话术不存在")
return
}
item := model.ScriptFeedback{TenantID: p.TenantID, RunID: run.ID, StageID: script.StageID, ScriptID: script.ID, FeedbackType: input.FeedbackType, OperatorID: p.UserID, Note: input.Note}
if err := h.db.Create(&item).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术反馈失败")
return
}
payload, _ := json.Marshal(input)
if err := h.db.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: input.NodeKey, Action: "script_feedback", Payload: datatypes.JSON(payload)}).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术反馈失败")
return
}
response.OK(c, gin.H{"recorded": true})
}
func sortEdges(edges []model.SOPEdge) {
sort.SliceStable(edges, func(i, j int) bool {
leftDefault := defaultCondition(edges[i].Condition)
@@ -368,6 +468,40 @@ func defaultCondition(raw []byte) bool {
return ok && len(object) == 0
}
func (h *Handler) RecordScriptUsage(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
if !ok {
return
}
var input struct {
NodeKey string `json:"node_key"`
ScriptKey string `json:"script_key"`
ScriptTitle string `json:"script_title"`
Template string `json:"template"`
Scene string `json:"scene"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术使用记录格式不正确")
return
}
var run model.SOPRun
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&run).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
if !canOperateRun(p, run) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
}
payload, _ := json.Marshal(input)
if err := h.db.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: input.NodeKey, Action: "script_used", Payload: datatypes.JSON(payload)}).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术使用记录失败")
return
}
response.OK(c, gin.H{"recorded": true})
}
func (h *Handler) Finish(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
id, ok := runID(c)
@@ -492,20 +626,12 @@ func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return
}
answers := map[string]interface{}{}
_ = json.Unmarshal(item.Answers, &answers)
derived := map[string]interface{}{}
_ = json.Unmarshal(item.Derived, &derived)
input := map[string]interface{}{}
_ = json.Unmarshal(item.Input, &input)
context := runtimeContext(input, derived, answers)
context["__knowledge_snapshot"] = json.RawMessage(item.KnowledgeSnapshot)
nodeView, err := h.nodeView(h.db, node, item.TenantID, context)
nodeView, err := nodeView(h.db, item, node)
if err != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的历史知识内容不存在")
response.Error(c, http.StatusInternalServerError, "STAGE_FAILED", "生成当前阶段内容失败")
return
}
outputs, err := buildScenarioOutputs(h.db, item, nodeView.Outputs)
outputs, err := buildScenarioOutputs(h.db, item)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败")
return

11
internal/run/helpers.go Normal file
View File

@@ -0,0 +1,11 @@
package run
// containsString reports whether the slice contains the value.
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -1,510 +0,0 @@
package run
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm"
)
var knowledgePlaceholderPattern = regexp.MustCompile(`\{\{\s*(?:input|derived|form)\.([A-Za-z][A-Za-z0-9_]*)\s*\}\}`)
type NodeView struct {
NodeKey string `json:"node_key"`
Type string `json:"type"`
Title string `json:"title"`
Content string `json:"content"`
Config json.RawMessage `json:"config,omitempty"`
Fields []PublicFieldView `json:"fields,omitempty"`
Presentation *NodePresentation `json:"presentation,omitempty"`
Outputs []KnowledgeGroup `json:"outputs"`
Collection *KnowledgeCollectionView `json:"collection,omitempty"`
}
type PublicFieldView 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"`
}
type KnowledgeGroup struct {
Key string `json:"key"`
Name string `json:"name"`
Type string `json:"type"`
Content json.RawMessage `json:"content"`
Relations map[string][]KnowledgeItemView `json:"relations"`
Suggested bool `json:"suggested,omitempty"`
}
type KnowledgeItemView struct {
Key string `json:"key"`
Name string `json:"name"`
Type string `json:"type"`
Content json.RawMessage `json:"content"`
}
type knowledgeNodeConfig struct {
KnowledgeSelector *knowledgeSelector `json:"knowledge_selector"`
KnowledgeCollection *knowledgeCollectionConfig `json:"knowledge_collection"`
}
type knowledgeCollectionConfig struct {
ContextFieldKeys []string `json:"context_field_keys"`
ContextTitle string `json:"context_title"`
ContextHint string `json:"context_hint"`
SelectionTitle string `json:"selection_title"`
SelectionHint string `json:"selection_hint"`
Steps []knowledgeCollectionStep `json:"steps"`
}
type knowledgeCollectionStep struct {
FieldKey string `json:"field_key"`
Name string `json:"name"`
Root bool `json:"root"`
CandidateScope string `json:"candidate_scope"`
KnowledgeTypes []string `json:"knowledge_types"`
FromField string `json:"from_field"`
RelationType string `json:"relation_type"`
Required bool `json:"required"`
Multiple bool `json:"multiple"`
}
type KnowledgeCollectionView struct {
ContextFields []PublicFieldView `json:"context_fields"`
ContextTitle string `json:"context_title"`
ContextHint string `json:"context_hint"`
SelectionTitle string `json:"selection_title"`
SelectionHint string `json:"selection_hint"`
Steps []KnowledgeCollectionStepView `json:"steps"`
}
type KnowledgeCollectionStepView struct {
FieldKey string `json:"field_key"`
Name string `json:"name"`
Required bool `json:"required"`
Multiple bool `json:"multiple"`
FromField string `json:"from_field,omitempty"`
Options []KnowledgeCollectionOption `json:"options"`
}
type KnowledgeCollectionOption struct {
Value string `json:"value"`
Label string `json:"label"`
Parents []string `json:"parents,omitempty"`
Suggested bool `json:"suggested,omitempty"`
}
type knowledgeSelector struct {
DerivedField string `json:"derived_field"`
AnswerField string `json:"answer_field"`
CandidateScope string `json:"candidate_scope"`
KnowledgeTypes []string `json:"knowledge_types"`
KnowledgeKeys []string `json:"knowledge_keys"`
RelationTypes []string `json:"relation_types"`
RelationLabels map[string]string `json:"relation_labels"`
}
func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64, answers map[string]interface{}) (NodeView, error) {
view := NodeView{NodeKey: node.NodeKey, Type: node.Type, Title: node.Title, Content: node.Content, Config: json.RawMessage(node.Config)}
if node.Type == "start" {
presentation, err := loadStartPresentation(db, node, tenantID, answers)
if err != nil {
return view, err
}
view.Presentation = presentation
}
if node.Type == "question" || node.Type == "choice" || node.Type == "form" {
fields, err := loadPublicNodeFields(db, node, tenantID)
if err != nil {
return view, err
}
view.Fields = fields
}
if node.Type != "knowledge" {
return view, nil
}
var config knowledgeNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil {
return view, err
}
if config.KnowledgeSelector != nil {
outputs, err := loadKnowledgeOutputs(db, node, tenantID, answers, *config.KnowledgeSelector)
view.Outputs = outputs
if err != nil {
return view, err
}
if config.KnowledgeCollection != nil {
collection, err := loadKnowledgeCollection(db, node, tenantID, *config.KnowledgeCollection, outputs, answers)
view.Collection = &collection
return view, err
}
return view, nil
}
return view, nil
}
func loadKnowledgeCollection(db *gorm.DB, node model.SOPNode, tenantID uint64, config knowledgeCollectionConfig, roots []KnowledgeGroup, context map[string]interface{}) (KnowledgeCollectionView, error) {
fields, err := loadPublicFieldsByKeys(db, node, tenantID, config.ContextFieldKeys)
if err != nil {
return KnowledgeCollectionView{}, err
}
view := KnowledgeCollectionView{ContextFields: fields, ContextTitle: config.ContextTitle, ContextHint: config.ContextHint, SelectionTitle: config.SelectionTitle, SelectionHint: config.SelectionHint, Steps: make([]KnowledgeCollectionStepView, 0, len(config.Steps))}
childrenByParent := map[string]map[string][]string{}
allItems := make([]model.KnowledgeItem, 0)
if raw, ok := context["__knowledge_snapshot"].(json.RawMessage); ok && len(raw) > 0 {
snapshot, parseErr := parseKnowledgeSnapshot(raw)
if parseErr != nil {
return KnowledgeCollectionView{}, parseErr
}
byID := map[uint64]model.KnowledgeItem{}
for _, item := range snapshot.Items {
byID[item.ID] = item
if item.Status == "active" {
allItems = append(allItems, item)
}
}
for _, relation := range snapshot.Relations {
from, fromOK := byID[relation.FromKnowledgeID]
to, toOK := byID[relation.ToKnowledgeID]
if !fromOK || !toOK || from.Status != "active" || to.Status != "active" {
continue
}
if childrenByParent[from.Name] == nil {
childrenByParent[from.Name] = map[string][]string{}
}
childrenByParent[from.Name][relation.RelationType] = appendUnique(childrenByParent[from.Name][relation.RelationType], to.Name)
}
}
suggestedRoots := make(map[string]bool, len(roots))
rootOptions := make([]KnowledgeCollectionOption, 0, len(roots))
for _, root := range roots {
suggestedRoots[root.Name] = root.Suggested
rootOptions = append(rootOptions, KnowledgeCollectionOption{Value: root.Name, Label: root.Name, Suggested: root.Suggested})
}
for _, step := range config.Steps {
item := KnowledgeCollectionStepView{FieldKey: step.FieldKey, Name: step.Name, Required: step.Required, Multiple: step.Multiple, FromField: step.FromField, Options: []KnowledgeCollectionOption{}}
if step.Root {
item.Options = append(item.Options, rootOptions...)
if step.CandidateScope == "all" {
seen := map[string]bool{}
for _, option := range item.Options {
seen[option.Value] = true
}
for _, knowledgeItem := range allItems {
if seen[knowledgeItem.Name] || (len(step.KnowledgeTypes) > 0 && !containsString(step.KnowledgeTypes, knowledgeItem.Type)) {
continue
}
item.Options = append(item.Options, KnowledgeCollectionOption{Value: knowledgeItem.Name, Label: knowledgeItem.Name, Suggested: suggestedRoots[knowledgeItem.Name]})
}
sort.SliceStable(item.Options, func(i, j int) bool {
if item.Options[i].Suggested != item.Options[j].Suggested {
return item.Options[i].Suggested
}
return item.Options[i].Label < item.Options[j].Label
})
}
} else {
seen := map[string]*KnowledgeCollectionOption{}
var parentStep *KnowledgeCollectionStepView
for index := range view.Steps {
if view.Steps[index].FieldKey == step.FromField {
parentStep = &view.Steps[index]
break
}
}
if parentStep != nil {
for _, parent := range parentStep.Options {
for _, childName := range childrenByParent[parent.Value][step.RelationType] {
option := seen[childName]
if option == nil {
option = &KnowledgeCollectionOption{Value: childName, Label: childName}
seen[childName] = option
}
option.Parents = appendUnique(option.Parents, parent.Value)
}
}
}
for _, option := range seen {
item.Options = append(item.Options, *option)
}
sort.Slice(item.Options, func(i, j int) bool { return item.Options[i].Label < item.Options[j].Label })
}
view.Steps = append(view.Steps, item)
}
return view, nil
}
func loadPublicFieldsByKeys(db *gorm.DB, node model.SOPNode, tenantID uint64, keys []string) ([]PublicFieldView, error) {
if len(keys) == 0 {
return []PublicFieldView{}, nil
}
var fields []model.ScenarioField
if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPID, tenantID, keys).Find(&fields).Error; err != nil {
return nil, err
}
byKey := map[string]model.ScenarioField{}
for _, field := range fields {
byKey[field.FieldKey] = field
}
result := make([]PublicFieldView, 0, len(keys))
for _, key := range keys {
if field, ok := byKey[key]; ok {
result = append(result, PublicFieldView{Key: field.FieldKey, Name: field.FieldName, Type: field.FieldType, Required: field.Required, Options: json.RawMessage(field.Options), Validation: json.RawMessage(field.Validation)})
}
}
return result, nil
}
func appendUnique(values []string, value string) []string {
for _, existing := range values {
if existing == value {
return values
}
}
return append(values, value)
}
func loadPublicNodeFields(db *gorm.DB, node model.SOPNode, tenantID uint64) ([]PublicFieldView, error) {
var config answerNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil {
return nil, err
}
keys := config.FieldKeys
if config.FieldKey != "" {
keys = []string{config.FieldKey}
}
if len(keys) == 0 {
return nil, nil
}
var fields []model.ScenarioField
if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPID, tenantID, keys).Find(&fields).Error; err != nil {
return nil, err
}
byKey := make(map[string]model.ScenarioField, len(fields))
for _, field := range fields {
byKey[field.FieldKey] = field
}
views := make([]PublicFieldView, 0, len(keys))
for _, key := range keys {
field, ok := byKey[key]
if !ok {
continue
}
views = append(views, PublicFieldView{Key: field.FieldKey, Name: field.FieldName, Type: field.FieldType, Required: field.Required || config.Required || containsString(config.RequiredFieldKeys, key), Options: json.RawMessage(field.Options), Validation: json.RawMessage(field.Validation)})
}
return views, nil
}
func loadKnowledgeOutputs(db *gorm.DB, node model.SOPNode, tenantID uint64, context map[string]interface{}, selector knowledgeSelector) ([]KnowledgeGroup, error) {
var sopRow struct{ ScenarioID uint64 }
if err := db.Table("sop_nodes n").Select("s.scenario_id").Joins("JOIN sops s ON s.id = n.sop_id").Where("n.id = ? AND n.tenant_id = ?", node.ID, tenantID).Scan(&sopRow).Error; err != nil {
return nil, err
}
return loadKnowledgeOutputsForScenario(db, sopRow.ScenarioID, tenantID, context, selector)
}
func loadKnowledgeOutputsForScenario(db *gorm.DB, scenarioID, tenantID uint64, context map[string]interface{}, selector knowledgeSelector) ([]KnowledgeGroup, error) {
keys := append([]string{}, selector.KnowledgeKeys...)
if selector.DerivedField != "" {
value, _ := lookupContextValue(context, "derived."+selector.DerivedField)
keys = append(keys, stringSlice(value)...)
}
if selector.AnswerField != "" {
value, _ := lookupContextValue(context, "form."+selector.AnswerField)
keys = append(keys, stringSlice(value)...)
}
if len(keys) == 0 && selector.CandidateScope != "all" {
return []KnowledgeGroup{}, nil
}
keySet := make(map[string]bool, len(keys))
for _, key := range keys {
keySet[key] = true
}
items := make([]model.KnowledgeItem, 0)
relations := make([]model.KnowledgeRelation, 0)
snapshotTargets := map[uint64]model.KnowledgeItem{}
if raw, ok := context["__knowledge_snapshot"].(json.RawMessage); ok && len(raw) > 0 {
snapshot, err := parseKnowledgeSnapshot(raw)
if err != nil {
return nil, err
}
for _, item := range snapshot.Items {
snapshotTargets[item.ID] = item
if item.Status != "active" {
continue
}
if selector.CandidateScope != "all" && len(keys) > 0 && !knowledgeCandidateMatches(keySet, item) {
continue
}
if len(selector.KnowledgeTypes) > 0 && !containsString(selector.KnowledgeTypes, item.Type) {
continue
}
items = append(items, item)
}
for _, relation := range snapshot.Relations {
if len(selector.RelationTypes) > 0 && !containsString(selector.RelationTypes, relation.RelationType) {
continue
}
relations = append(relations, relation)
}
} else {
query := db.Where("tenant_id = ? AND scenario_id = ? AND status = ?", tenantID, scenarioID, "active")
if selector.CandidateScope != "all" && len(keys) > 0 {
query = query.Where("item_key IN ? OR name IN ?", keys, keys)
}
if len(selector.KnowledgeTypes) > 0 {
query = query.Where("type IN ?", selector.KnowledgeTypes)
}
if err := query.Order("sort_order, id").Find(&items).Error; err != nil {
return nil, err
}
}
ids := make([]uint64, 0, len(items))
for _, item := range items {
if selector.CandidateScope == "all" || len(keySet) == 0 || knowledgeCandidateMatches(keySet, item) {
ids = append(ids, item.ID)
}
}
if len(relations) == 0 && len(ids) > 0 {
rq := db.Where("tenant_id = ? AND scenario_id = ? AND from_knowledge_id IN ?", tenantID, scenarioID, ids)
if len(selector.RelationTypes) > 0 {
rq = rq.Where("relation_type IN ?", selector.RelationTypes)
}
if err := rq.Order("sort_order,id").Find(&relations).Error; err != nil {
return nil, err
}
}
targetIDs := make([]uint64, 0, len(relations))
for _, rel := range relations {
targetIDs = append(targetIDs, rel.ToKnowledgeID)
}
targets := make([]model.KnowledgeItem, 0)
if len(snapshotTargets) > 0 {
for _, id := range targetIDs {
if target, ok := snapshotTargets[id]; ok && target.Status == "active" {
targets = append(targets, target)
}
}
} else if len(targetIDs) > 0 {
if err := db.Where("tenant_id = ? AND id IN ? AND status = ?", tenantID, targetIDs, "active").Find(&targets).Error; err != nil {
return nil, err
}
}
targetMap := map[uint64]model.KnowledgeItem{}
for _, target := range targets {
targetMap[target.ID] = target
}
relationMap := map[uint64]map[string][]KnowledgeItemView{}
for _, rel := range relations {
matched, err := matchCondition(json.RawMessage(rel.Condition), context)
if err != nil {
return nil, fmt.Errorf("知识关系 %d 条件不正确: %w", rel.ID, err)
}
if !matched {
continue
}
target, ok := targetMap[rel.ToKnowledgeID]
if !ok {
continue
}
if relationMap[rel.FromKnowledgeID] == nil {
relationMap[rel.FromKnowledgeID] = map[string][]KnowledgeItemView{}
}
relationMap[rel.FromKnowledgeID][rel.RelationType] = append(relationMap[rel.FromKnowledgeID][rel.RelationType], KnowledgeItemView{Key: target.ItemKey, Name: target.Name, Type: target.Type, Content: renderKnowledgeContent(target.Content, context)})
}
outputs := make([]KnowledgeGroup, 0, len(items))
for _, item := range items {
outputs = append(outputs, KnowledgeGroup{Key: item.ItemKey, Name: item.Name, Type: item.Type, Content: renderKnowledgeContent(item.Content, context), Relations: relationMap[item.ID], Suggested: knowledgeCandidateMatches(keySet, item)})
}
return outputs, nil
}
func knowledgeCandidateMatches(candidates map[string]bool, item model.KnowledgeItem) bool {
return candidates[item.ItemKey] || candidates[item.Name]
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func renderKnowledgeContent(raw []byte, context map[string]interface{}) json.RawMessage {
var value interface{}
if json.Unmarshal(raw, &value) != nil {
return json.RawMessage(raw)
}
value = renderKnowledgeValue(value, context)
rendered, _ := json.Marshal(value)
return rendered
}
func renderKnowledgeValue(value interface{}, context map[string]interface{}) interface{} {
switch typed := value.(type) {
case string:
return knowledgePlaceholderPattern.ReplaceAllStringFunc(typed, func(token string) string {
match := knowledgePlaceholderPattern.FindStringSubmatch(token)
if len(match) != 2 {
return token
}
resolved, ok := lookupContextValue(context, tokenNamespaceKey(token, match[1]))
if !ok || resolved == nil {
return "未提供"
}
if values, ok := resolved.([]interface{}); ok {
parts := make([]string, 0, len(values))
for _, item := range values {
parts = append(parts, fmt.Sprint(item))
}
return strings.Join(parts, "、")
}
return fmt.Sprint(resolved)
})
case []interface{}:
for index, item := range typed {
typed[index] = renderKnowledgeValue(item, context)
}
return typed
case map[string]interface{}:
for key, item := range typed {
typed[key] = renderKnowledgeValue(item, context)
}
return typed
default:
return value
}
}
func tokenNamespaceKey(token, key string) string {
trimmed := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(token, "{{"), "}}"))
if strings.Contains(trimmed, ".") {
return trimmed
}
return key
}
func stringSlice(value interface{}) []string {
result := []string{}
switch values := value.(type) {
case []interface{}:
for _, item := range values {
if text, ok := item.(string); ok {
result = append(result, text)
}
}
case []string:
return values
case string:
return []string{values}
}
return result
}

View File

@@ -1,79 +0,0 @@
package run
import (
"encoding/json"
"fmt"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
func validateKnowledgeSelections(node model.SOPNode, run model.SOPRun, answers map[string]interface{}) error {
if node.Type != "knowledge" {
return nil
}
var config knowledgeNodeConfig
if json.Unmarshal(node.Config, &config) != nil || config.KnowledgeCollection == nil {
return nil
}
snapshot, err := parseKnowledgeSnapshot(run.KnowledgeSnapshot)
if err != nil {
return err
}
itemsByID := map[uint64]model.KnowledgeItem{}
itemsByName := map[string]model.KnowledgeItem{}
for _, item := range snapshot.Items {
if item.Status == "active" {
itemsByID[item.ID] = item
itemsByName[item.Name] = item
}
}
relations := map[uint64]map[string]map[uint64]bool{}
for _, rel := range snapshot.Relations {
if relations[rel.FromKnowledgeID] == nil {
relations[rel.FromKnowledgeID] = map[string]map[uint64]bool{}
}
if relations[rel.FromKnowledgeID][rel.RelationType] == nil {
relations[rel.FromKnowledgeID][rel.RelationType] = map[uint64]bool{}
}
relations[rel.FromKnowledgeID][rel.RelationType][rel.ToKnowledgeID] = true
}
selected := map[string][]model.KnowledgeItem{}
derived := map[string]interface{}{}
_ = json.Unmarshal(run.Derived, &derived)
rootCandidates := map[string]bool{}
if config.KnowledgeSelector != nil {
for _, value := range stringSlice(derived[config.KnowledgeSelector.DerivedField]) {
rootCandidates[value] = true
}
}
for _, step := range config.KnowledgeCollection.Steps {
values, _ := stringValues(answers[step.FieldKey])
for _, value := range values {
item, ok := itemsByName[value]
if !ok {
return fmt.Errorf("%s包含不存在的知识选项%s", step.Name, value)
}
if step.Root {
if len(step.KnowledgeTypes) > 0 && !containsString(step.KnowledgeTypes, item.Type) {
return fmt.Errorf("%s的知识类型不正确%s", step.Name, value)
}
if step.CandidateScope != "all" && !rootCandidates[item.Name] && !rootCandidates[item.ItemKey] {
return fmt.Errorf("%s不属于本次订单推断结果%s", step.Name, value)
}
} else {
valid := false
for _, parent := range selected[step.FromField] {
if relations[parent.ID][step.RelationType][item.ID] {
valid = true
break
}
}
if !valid {
return fmt.Errorf("%s与已选择的上级知识不关联%s", step.Name, value)
}
}
selected[step.FieldKey] = append(selected[step.FieldKey], item)
}
}
return nil
}

View File

@@ -1,62 +0,0 @@
package run
import (
"encoding/json"
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/datatypes"
)
func TestValidateKnowledgeSelectionsRejectsUnrelatedPlan(t *testing.T) {
config := map[string]interface{}{
"knowledge_selector": map[string]interface{}{"derived_field": "matched"},
"knowledge_collection": map[string]interface{}{"steps": []map[string]interface{}{
{"field_key": "segments", "name": "客户分群", "root": true, "required": true, "multiple": true},
{"field_key": "strategies", "name": "推荐策略", "from_field": "segments", "relation_type": "matched_strategy", "required": true, "multiple": true},
{"field_key": "offers", "name": "推荐内容", "from_field": "strategies", "relation_type": "recommended_offer", "required": true, "multiple": true},
}},
}
configRaw, _ := json.Marshal(config)
snapshotRaw, _ := json.Marshal(knowledgeSnapshot{
Items: []model.KnowledgeItem{
{Base: model.Base{ID: 1}, ItemKey: "vip", Name: "高价值客户", Status: "active"},
{Base: model.Base{ID: 2}, ItemKey: "renewal", Name: "续费策略", Status: "active"},
{Base: model.Base{ID: 3}, ItemKey: "annual", Name: "年度套餐", Status: "active"},
{Base: model.Base{ID: 4}, ItemKey: "trial", Name: "试用课程", Status: "active"},
},
Relations: []model.KnowledgeRelation{
{FromKnowledgeID: 1, ToKnowledgeID: 2, RelationType: "matched_strategy"},
{FromKnowledgeID: 2, ToKnowledgeID: 3, RelationType: "recommended_offer"},
},
})
derivedRaw, _ := json.Marshal(map[string]interface{}{"matched": []string{"高价值客户"}})
run := model.SOPRun{Derived: datatypes.JSON(derivedRaw), KnowledgeSnapshot: datatypes.JSON(snapshotRaw)}
node := model.SOPNode{Type: "knowledge", Config: datatypes.JSON(configRaw)}
valid := map[string]interface{}{"segments": []interface{}{"高价值客户"}, "strategies": []interface{}{"续费策略"}, "offers": []interface{}{"年度套餐"}}
if err := validateKnowledgeSelections(node, run, valid); err != nil {
t.Fatalf("valid selection rejected: %v", err)
}
invalid := map[string]interface{}{"segments": []interface{}{"高价值客户"}, "strategies": []interface{}{"续费策略"}, "offers": []interface{}{"试用课程"}}
if err := validateKnowledgeSelections(node, run, invalid); err == nil {
t.Fatal("unrelated plan should be rejected")
}
}
func TestValidateKnowledgeSelectionsAllowsAdditionalRootFromConfiguredKnowledgeType(t *testing.T) {
configRaw := datatypes.JSON([]byte(`{"knowledge_selector":{"derived_field":"matched"},"knowledge_collection":{"steps":[{"field_key":"symptoms","name":"症状","root":true,"candidate_scope":"all","knowledge_types":["symptom"],"required":true,"multiple":true}]}}`))
snapshotRaw, _ := json.Marshal(knowledgeSnapshot{Items: []model.KnowledgeItem{
{Base: model.Base{ID: 1}, ItemKey: "diarrhea", Name: "腹泻", Type: "symptom", Status: "active"},
{Base: model.Base{ID: 2}, ItemKey: "vomiting", Name: "呕吐", Type: "symptom", Status: "active"},
{Base: model.Base{ID: 3}, ItemKey: "disease", Name: "胃肠炎", Type: "disease", Status: "active"},
}})
derivedRaw, _ := json.Marshal(map[string]interface{}{"matched": []string{"腹泻"}})
run := model.SOPRun{Derived: datatypes.JSON(derivedRaw), KnowledgeSnapshot: datatypes.JSON(snapshotRaw)}
node := model.SOPNode{Type: "knowledge", Config: configRaw}
if err := validateKnowledgeSelections(node, run, map[string]interface{}{"symptoms": []interface{}{"腹泻", "呕吐"}}); err != nil {
t.Fatalf("additional symptom should be accepted: %v", err)
}
if err := validateKnowledgeSelections(node, run, map[string]interface{}{"symptoms": []interface{}{"胃肠炎"}}); err == nil {
t.Fatal("knowledge item of another type should be rejected")
}
}

View File

@@ -1,88 +0,0 @@
package run
import (
"encoding/json"
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
func TestRenderKnowledgeContent(t *testing.T) {
got := string(renderKnowledgeContent([]byte(`{"template":"您好{{input.name}},标签:{{derived.tags}}"}`), map[string]interface{}{"name": "王女士", "tags": []interface{}{"高意向", "复购"}}))
want := `{"template":"您好王女士,标签:高意向、复购"}`
if got != want {
t.Fatalf("got %s want %s", got, want)
}
}
func TestRenderKnowledgeContentSupportsFormNamespace(t *testing.T) {
got := string(renderKnowledgeContent([]byte(`{"template":"结果:{{form.call_result}}"}`), runtimeContext(nil, nil, map[string]interface{}{"call_result": "已接受"})))
if got != `{"template":"结果:已接受"}` {
t.Fatalf("got %s", got)
}
}
func TestTemplateNamespacesDoNotOverwriteEachOther(t *testing.T) {
context := runtimeContext(map[string]interface{}{"status": "input"}, map[string]interface{}{"status": "derived"}, map[string]interface{}{"status": "form"})
got := string(renderKnowledgeContent([]byte(`{"template":"{{input.status}}/{{derived.status}}/{{form.status}}"}`), context))
if got != `{"template":"input/derived/form"}` {
t.Fatalf("got %s", got)
}
}
func TestKnowledgeRelationCondition(t *testing.T) {
condition := json.RawMessage(`{"field":"derived.segment","operator":"equals","value":"vip"}`)
matched, err := matchCondition(condition, map[string]interface{}{"segment": "vip"})
if err != nil || !matched {
t.Fatalf("matched=%v err=%v", matched, err)
}
matched, err = matchCondition(condition, map[string]interface{}{"segment": "normal"})
if err != nil || matched {
t.Fatalf("matched=%v err=%v", matched, err)
}
}
func TestSnapshotRelationsHonorSelector(t *testing.T) {
snapshot := knowledgeSnapshot{Relations: []model.KnowledgeRelation{
{FromKnowledgeID: 1, ToKnowledgeID: 2, RelationType: "recommended_copy"},
{FromKnowledgeID: 1, ToKnowledgeID: 3, RelationType: "internal_note"},
}}
raw, err := json.Marshal(snapshot)
if err != nil {
t.Fatal(err)
}
parsed, err := parseKnowledgeSnapshot(raw)
if err != nil {
t.Fatal(err)
}
selector := knowledgeSelector{RelationTypes: []string{"recommended_copy"}}
filtered := make([]model.KnowledgeRelation, 0)
for _, relation := range parsed.Relations {
if len(selector.RelationTypes) == 0 || containsString(selector.RelationTypes, relation.RelationType) {
filtered = append(filtered, relation)
}
}
if len(filtered) != 1 || filtered[0].RelationType != "recommended_copy" {
t.Fatalf("filtered relations = %#v", filtered)
}
}
func TestKnowledgeSelectorWithoutCandidateKeysReturnsEmpty(t *testing.T) {
outputs, err := loadKnowledgeOutputsForScenario(nil, 1, 1, map[string]interface{}{}, knowledgeSelector{DerivedField: "matched", KnowledgeTypes: []string{"symptom"}})
if err != nil {
t.Fatal(err)
}
if len(outputs) != 0 {
t.Fatalf("outputs = %#v", outputs)
}
}
func TestKnowledgeCandidateMatchesKeyOrDisplayName(t *testing.T) {
item := model.KnowledgeItem{ItemKey: "xlsx_symptom_123", Name: "腹泻"}
if !knowledgeCandidateMatches(map[string]bool{"腹泻": true}, item) {
t.Fatal("display name should match an external symptom tag")
}
if !knowledgeCandidateMatches(map[string]bool{"xlsx_symptom_123": true}, item) {
t.Fatal("item key should remain supported")
}
}

View File

@@ -6,7 +6,7 @@ import (
)
func TestNodeViewDoesNotExposeInternalSnapshotFields(t *testing.T) {
raw, err := json.Marshal(NodeView{NodeKey: "knowledge", Type: "knowledge", Title: "知识", Content: "内容"})
raw, err := json.Marshal(NodeView{NodeKey: "stage", Type: "stage", Title: "知识", Content: "内容"})
if err != nil {
t.Fatal(err)
}
@@ -14,7 +14,7 @@ func TestNodeViewDoesNotExposeInternalSnapshotFields(t *testing.T) {
if err := json.Unmarshal(raw, &value); err != nil {
t.Fatal(err)
}
for _, key := range []string{"id", "tenant_id", "sop_id", "sop_version_id", "config", "position_x", "position_y"} {
for _, key := range []string{"id", "tenant_id", "sop_id", "config", "position_x", "position_y"} {
if _, exists := value[key]; exists {
t.Fatalf("public node contains internal field %s: %s", key, raw)
}

View File

@@ -27,7 +27,7 @@ type OutputView struct {
Value interface{} `json:"value"`
}
func buildScenarioOutputs(db *gorm.DB, run model.SOPRun, knowledge []KnowledgeGroup) ([]OutputView, error) {
func buildScenarioOutputs(db *gorm.DB, run model.SOPRun) ([]OutputView, error) {
var scenario model.Scenario
if err := db.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", run.SOPID, run.TenantID).First(&scenario).Error; err != nil {
return nil, err
@@ -38,10 +38,10 @@ func buildScenarioOutputs(db *gorm.DB, run model.SOPRun, knowledge []KnowledgeGr
_ = json.Unmarshal(run.Input, &input)
_ = json.Unmarshal(run.Derived, &derived)
_ = json.Unmarshal(run.Answers, &answers)
return buildOutputViews(scenario.OutputSchema, input, derived, answers, knowledge, run.Status, run.Result)
return buildOutputViews(scenario.OutputSchema, input, derived, answers, run.Status, run.Result)
}
func buildOutputViews(raw []byte, input, derived, answers map[string]interface{}, knowledge []KnowledgeGroup, status, result string) ([]OutputView, error) {
func buildOutputViews(raw []byte, input, derived, answers map[string]interface{}, status, result string) ([]OutputView, error) {
var schema outputSchema
if err := json.Unmarshal(raw, &schema); err != nil {
return nil, err
@@ -57,17 +57,6 @@ func buildOutputViews(raw []byte, input, derived, answers map[string]interface{}
sourceField = field.Key
}
value, ok = derived[sourceField]
case "knowledge":
if sourceField == "" {
value, ok = knowledge, true
} else {
for _, group := range knowledge {
if group.Key == sourceField {
value, ok = group, true
break
}
}
}
case "form":
if sourceField == "" {
sourceField = field.Key

View File

@@ -1,21 +0,0 @@
package run
import "testing"
func TestBuildOutputViewsSupportsKnowledge(t *testing.T) {
knowledge := []KnowledgeGroup{{Key: "soft_stool", Name: "软便", Type: "symptom"}}
raw := []byte(`{"fields":[{"key":"recommended","name":"推荐知识","type":"array","source":"knowledge"},{"key":"symptom","name":"症状","type":"object","source":"knowledge","source_field":"soft_stool"}]}`)
outputs, err := buildOutputViews(raw, nil, nil, nil, knowledge, "preview", "")
if err != nil {
t.Fatal(err)
}
if len(outputs) != 2 {
t.Fatalf("outputs=%#v", outputs)
}
if groups, ok := outputs[0].Value.([]KnowledgeGroup); !ok || len(groups) != 1 {
t.Fatalf("knowledge output=%#v", outputs[0].Value)
}
if group, ok := outputs[1].Value.(KnowledgeGroup); !ok || group.Key != "soft_stool" {
t.Fatalf("selected output=%#v", outputs[1].Value)
}
}

View File

@@ -8,6 +8,7 @@ import (
"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"
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
"github.com/gin-gonic/gin"
)
@@ -21,7 +22,7 @@ func (h *Handler) PreviewScenario(c *gin.Context) {
var body struct {
Input map[string]interface{} `json:"input"`
InitialValues map[string]interface{} `json:"initial_values"`
Selector knowledgeSelector `json:"knowledge_selector"`
StageKey string `json:"stage_key"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "预览参数格式不正确")
@@ -47,16 +48,29 @@ func (h *Handler) PreviewScenario(c *gin.Context) {
response.Error(c, http.StatusUnprocessableEntity, "RULE_EVALUATION_FAILED", err.Error())
return
}
context := runtimeContext(mapped, derived, nil)
knowledge, err := loadKnowledgeOutputsForScenario(h.db, scenarioID, p.TenantID, context, body.Selector)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成知识预览失败")
return
}
outputs, err := buildOutputViews(scenario.OutputSchema, mapped, derived, map[string]interface{}{}, knowledge, "preview", "")
outputs, err := buildOutputViews(scenario.OutputSchema, mapped, derived, map[string]interface{}{}, "preview", "")
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败")
return
}
response.OK(c, gin.H{"input": mapped, "derived": derived, "matched_rules": matchedRules, "knowledge": knowledge, "outputs": outputs})
data := gin.H{"input": mapped, "derived": derived, "matched_rules": matchedRules, "outputs": outputs}
pkg, loadErr := scriptkit.LoadPackageByScenario(h.db, p.TenantID, scenarioID)
if loadErr != nil {
response.OK(c, data)
return
}
stageKey := body.StageKey
if stageKey == "" {
stageKey = pkg.Package.StartStageKey
}
stage, ok := pkg.StageByKey(stageKey)
if !ok {
response.OK(c, data)
return
}
ctx := scriptkit.RenderContext{Input: mapped, Derived: derived, Form: map[string]interface{}{}}
state := scriptkit.ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}
weights := pkg.RecomputeWeights(pkg.InitialWeights(ctx), state, ctx)
data["stage"] = pkg.BuildStageView(stage, weights, ctx, state, []scriptkit.FormFieldView{})
response.OK(c, data)
}

View File

@@ -14,6 +14,7 @@ import (
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"git.iwork-ai.com/xdc/iqudo-top1/internal/resultcontract"
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/datatypes"
@@ -82,21 +83,22 @@ func (h *Handler) PublicStart(c *gin.Context) {
return
}
derivedRaw, _ := json.Marshal(derived)
knowledgeRaw, err := snapshotKnowledge(h.db, scenario.TenantID, scenario.ID)
if err != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_SNAPSHOT_FAILED", "生成知识快照失败")
return
}
externalRef := body.ExternalRef
if externalRef == "" {
externalRef = "run-" + uuid.NewString()
}
run := model.SOPRun{TenantID: scenario.TenantID, SOPID: sop.ID, OperatorID: scenario.CreatedBy, ExternalRef: externalRef, CurrentNodeKey: sop.StartNodeKey, Status: "running", Answers: datatypes.JSON(raw), Input: datatypes.JSON(raw), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), KnowledgeSnapshot: datatypes.JSON(knowledgeRaw), StartedAt: time.Now()}
scriptStateRaw, _ := json.Marshal(scriptkit.ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}})
run := model.SOPRun{TenantID: scenario.TenantID, SOPID: sop.ID, OperatorID: scenario.CreatedBy, ExternalRef: externalRef, CurrentNodeKey: sop.StartNodeKey, Status: "running", Answers: datatypes.JSON(raw), Input: datatypes.JSON(raw), Derived: datatypes.JSON(derivedRaw), Outputs: datatypes.JSON([]byte(`[]`)), ScriptState: datatypes.JSON(scriptStateRaw), StartedAt: time.Now()}
token := uuid.NewString() + uuid.NewString()
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&run).Error; err != nil {
return err
}
if pkg, loadErr := loadRunPackage(tx, run); loadErr == nil {
if persistErr := persistRunWeights(tx, run, pkg, runWeights(run, pkg)); persistErr != nil {
return persistErr
}
}
if err := tx.Create(&model.PublicRunSession{TenantID: scenario.TenantID, RunID: run.ID, TokenHash: publicTokenHash(token), ExpiresAt: time.Now().Add(24 * time.Hour)}).Error; err != nil {
return err
}
@@ -160,6 +162,14 @@ func (h *Handler) PublicSubmit(c *gin.Context) {
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
return err
}
if node.Type == "stage" {
if len(body.Answers) > 0 {
if err := applyStageAnswer(tx, &run, node, stageAnswerInput{NodeKey: body.NodeKey, Answers: body.Answers}); err != nil {
return err
}
}
return advanceFromStage(tx, &run, node)
}
fields := make([]model.ScenarioField, 0)
if err := tx.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", run.SOPID, run.TenantID).Find(&fields).Error; err != nil {
return err
@@ -174,11 +184,14 @@ func (h *Handler) PublicSubmit(c *gin.Context) {
}
answerRaw, _ := json.Marshal(answers)
run.Answers = datatypes.JSON(answerRaw)
if err := tx.Model(&run).Update("answers", run.Answers).Error; err != nil {
return err
}
payload, _ := json.Marshal(body.Answers)
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "answer", Payload: datatypes.JSON(payload)}).Error; err != nil {
return err
}
if err := advancePublicRun(tx, &run); err != nil {
if err := advanceRunNode(tx, &run); err != nil {
return err
}
if run.Status == "completed" {
@@ -193,6 +206,44 @@ func (h *Handler) PublicSubmit(c *gin.Context) {
h.respondPublicRun(c, run, "")
}
// PublicAnswer records one stage script/dimension answer without advancing.
func (h *Handler) PublicAnswer(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
return
}
var body stageAnswerInput
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "回答格式不正确")
return
}
var run model.SOPRun
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := lockPublicRun(tx, session, &run); err != nil {
return err
}
if run.Status != "running" {
return errPublicRunCompleted
}
if body.NodeKey != "" && body.NodeKey != run.CurrentNodeKey {
return errPublicNodeChanged
}
var node model.SOPNode
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
return err
}
if node.Type != "stage" {
return errors.New("当前节点不是话术阶段")
}
return applyStageAnswer(tx, &run, node, body)
})
if err != nil {
h.respondPublicMutationError(c, err, "保存回答失败")
return
}
h.respondPublicRun(c, run, "")
}
func (h *Handler) PublicNext(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
@@ -210,13 +261,16 @@ func (h *Handler) PublicNext(c *gin.Context) {
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
return err
}
if node.Type == "question" || node.Type == "choice" || node.Type == "form" {
if node.Type == "stage" {
if err := advanceFromStage(tx, &run, node); err != nil {
return err
}
} else if node.Type == "question" || node.Type == "choice" || node.Type == "form" {
return errPublicInputNeeded
}
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "next", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
} else if err := advanceRunNode(tx, &run); err != nil {
return err
}
return advancePublicRun(tx, &run)
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "next", Payload: datatypes.JSON([]byte(`{}`))}).Error
})
if err != nil {
h.respondPublicMutationError(c, err, "推进节点失败")
@@ -225,6 +279,76 @@ func (h *Handler) PublicNext(c *gin.Context) {
h.respondPublicRun(c, run, "")
}
// PublicBack moves the run to the previous node.
func (h *Handler) PublicBack(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
return
}
var run model.SOPRun
err := h.db.Transaction(func(tx *gorm.DB) error {
if err := lockPublicRun(tx, session, &run); err != nil {
return err
}
if run.Status != "running" {
return errPublicRunCompleted
}
if err := backRunNode(tx, &run); err != nil {
return err
}
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "back", Payload: datatypes.JSON([]byte(`{}`))}).Error
})
if err != nil {
h.respondPublicMutationError(c, err, "返回上一步失败")
return
}
h.respondPublicRun(c, run, "")
}
// PublicFeedback records like/report/unreasonable feedback on a script.
func (h *Handler) PublicFeedback(c *gin.Context) {
session, run, ok := h.publicSession(c)
if !ok {
return
}
var body struct {
NodeKey string `json:"node_key"`
ScriptKey string `json:"script_key" binding:"required"`
FeedbackType string `json:"feedback_type" binding:"required"`
Note string `json:"note"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术反馈格式不正确")
return
}
if body.FeedbackType != "like" && body.FeedbackType != "report" && body.FeedbackType != "unreasonable" {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "话术反馈类型不正确")
return
}
pkg, err := loadRunPackage(h.db, run)
if err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景没有配置话术包")
return
}
script, found := pkg.ScriptByKey(body.ScriptKey)
if !found {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "话术不存在")
return
}
item := model.ScriptFeedback{TenantID: run.TenantID, RunID: run.ID, StageID: script.StageID, ScriptID: script.ID, FeedbackType: body.FeedbackType, OperatorID: run.OperatorID, Note: body.Note}
if err := h.db.Create(&item).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术反馈失败")
return
}
payload, _ := json.Marshal(body)
if err := h.db.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: body.NodeKey, Action: "script_feedback", Payload: datatypes.JSON(payload)}).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存话术反馈失败")
return
}
_ = session
response.OK(c, gin.H{"recorded": true})
}
func (h *Handler) PublicFinish(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
@@ -305,9 +429,15 @@ func (h *Handler) PublicReset(c *gin.Context) {
return err
}
run.CurrentNodeKey, run.Status, run.Result, run.FinalResult, run.CompletedAt, run.Answers = sop.StartNodeKey, "running", "", nil, nil, run.Input
if err := tx.Model(&run).Updates(map[string]interface{}{"current_node_key": run.CurrentNodeKey, "status": run.Status, "result": run.Result, "final_result": nil, "completed_at": nil, "answers": run.Input, "outputs": datatypes.JSON([]byte(`[]`))}).Error; err != nil {
run.ScriptState = datatypes.JSON([]byte(`{"script_answers":{},"dimension_selects":{}}`))
if err := tx.Model(&run).Updates(map[string]interface{}{"current_node_key": run.CurrentNodeKey, "status": run.Status, "result": run.Result, "final_result": nil, "completed_at": nil, "answers": run.Input, "outputs": datatypes.JSON([]byte(`[]`)), "script_state": run.ScriptState}).Error; err != nil {
return err
}
if pkg, loadErr := loadRunPackage(tx, run); loadErr == nil {
if persistErr := persistRunWeights(tx, run, pkg, runWeights(run, pkg)); persistErr != nil {
return persistErr
}
}
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "reset", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
return err
}
@@ -320,58 +450,6 @@ func (h *Handler) PublicReset(c *gin.Context) {
h.respondPublicRun(c, run, "")
}
func advancePublicRun(tx *gorm.DB, run *model.SOPRun) error {
var edges []model.SOPEdge
if err := tx.Where("sop_id = ? AND source_node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).Order("priority,id").Find(&edges).Error; err != nil {
return err
}
answers := map[string]interface{}{}
_ = json.Unmarshal(run.Answers, &answers)
derived := map[string]interface{}{}
_ = json.Unmarshal(run.Derived, &derived)
input := map[string]interface{}{}
_ = json.Unmarshal(run.Input, &input)
context := runtimeContext(input, derived, answers)
sortEdges(edges)
nextKey := ""
for _, edge := range edges {
matched, err := matchCondition(json.RawMessage(edge.Condition), context)
if err != nil {
return err
}
if matched {
nextKey = edge.TargetNodeKey
break
}
}
if nextKey == "" {
return errors.New("没有满足条件的下一节点")
}
var next model.SOPNode
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, nextKey, run.TenantID).First(&next).Error; err != nil {
return err
}
updates := map[string]interface{}{"current_node_key": nextKey}
if next.Type == "finish" || next.Type == "escalate" {
now := time.Now()
updates["status"] = "completed"
updates["completed_at"] = &now
updates["result"] = next.Type
run.Status, run.CompletedAt, run.Result = "completed", &now, next.Type
}
if err := tx.Model(run).Updates(updates).Error; err != nil {
return err
}
run.CurrentNodeKey = nextKey
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
return err
}
if run.Status == "completed" {
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "finish", Payload: datatypes.JSON([]byte(`{"source":"terminal_node"}`))}).Error
}
return nil
}
func lockPublicRun(tx *gorm.DB, session model.PublicRunSession, run *model.SOPRun) error {
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", session.RunID, session.TenantID).First(run).Error
}
@@ -397,21 +475,13 @@ func (h *Handler) respondPublicRun(c *gin.Context, run model.SOPRun, token strin
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return
}
answers := map[string]interface{}{}
_ = json.Unmarshal(run.Answers, &answers)
derived := map[string]interface{}{}
_ = json.Unmarshal(run.Derived, &derived)
input := map[string]interface{}{}
_ = json.Unmarshal(run.Input, &input)
context := runtimeContext(input, derived, answers)
context["__knowledge_snapshot"] = json.RawMessage(run.KnowledgeSnapshot)
view, err := h.nodeView(h.db, node, run.TenantID, context)
view, err := nodeView(h.db, run, node)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成节点输出失败")
return
}
view.Config = nil
outputs, err := buildScenarioOutputs(h.db, run, view.Outputs)
outputs, err := buildScenarioOutputs(h.db, run)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败")
return

View File

@@ -1,29 +0,0 @@
package run
import (
"encoding/json"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm"
)
type knowledgeSnapshot struct {
Items []model.KnowledgeItem `json:"items"`
Relations []model.KnowledgeRelation `json:"relations"`
}
func snapshotKnowledge(db *gorm.DB, tenantID, scenarioID uint64) ([]byte, error) {
var snapshot knowledgeSnapshot
if err := db.Where("tenant_id = ? AND scenario_id = ? AND status = ?", tenantID, scenarioID, "active").Order("sort_order,id").Find(&snapshot.Items).Error; err != nil {
return nil, err
}
if err := db.Where("tenant_id = ? AND scenario_id = ?", tenantID, scenarioID).Order("sort_order,id").Find(&snapshot.Relations).Error; err != nil {
return nil, err
}
return json.Marshal(snapshot)
}
func parseKnowledgeSnapshot(raw []byte) (knowledgeSnapshot, error) {
var snapshot knowledgeSnapshot
err := json.Unmarshal(raw, &snapshot)
return snapshot, err
}

462
internal/run/stage.go Normal file
View File

@@ -0,0 +1,462 @@
package run
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// stageNodeConfig is the config of a "stage" SOP node. It binds the node to a
// script package stage and optionally to a content supplement form.
type stageNodeConfig struct {
PackageID *uint64 `json:"package_id"`
StageKey string `json:"stage_key"`
FieldKeys []string `json:"field_keys"`
RequiredFieldKeys []string `json:"required_field_keys"`
}
// stageScriptAnswerInput is one script answer inside a batch stage submission.
type stageScriptAnswerInput struct {
ScriptKey string `json:"script_key"`
OptionKeys []string `json:"option_keys"`
Value string `json:"value"`
}
// stageAnswerInput is the shared payload of the stage answer endpoint. Clients
// answer questions locally and submit the whole stage once: script_answers
// carries every answered script, dimension_selects carries the manual
// dimension toggles and answers carries the content supplement form.
type stageAnswerInput struct {
NodeKey string `json:"node_key"`
ScriptKey string `json:"script_key"`
OptionKeys []string `json:"option_keys"`
Value string `json:"value"`
ScriptAnswers []stageScriptAnswerInput `json:"script_answers"`
DimensionSelects map[string]bool `json:"dimension_selects"`
Answers map[string]interface{} `json:"answers"`
DimensionValueKey string `json:"dimension_value_key"`
Selected *bool `json:"selected"`
}
func parseStageConfig(node model.SOPNode) (stageNodeConfig, error) {
var config stageNodeConfig
if len(node.Config) > 0 {
if err := json.Unmarshal(node.Config, &config); err != nil {
return config, fmt.Errorf("当前阶段节点配置不正确")
}
}
if config.StageKey == "" {
return config, errors.New("当前阶段节点没有绑定话术包阶段")
}
return config, nil
}
// loadRunPackage loads the script package that drives a run.
func loadRunPackage(db *gorm.DB, run model.SOPRun) (*scriptkit.Package, error) {
var scenarioID uint64
if err := db.Table("sops s").Select("s.scenario_id").Where("s.id = ? AND s.tenant_id = ?", run.SOPID, run.TenantID).Scan(&scenarioID).Error; err != nil {
return nil, err
}
pkg, err := scriptkit.LoadPackageByScenario(db, run.TenantID, scenarioID)
if err != nil {
return nil, errors.New("场景没有配置话术包")
}
return pkg, nil
}
func runScriptState(run model.SOPRun) scriptkit.ScriptState {
state := scriptkit.ScriptState{ScriptAnswers: map[string][]string{}, DimensionSelects: map[string]bool{}}
if len(run.ScriptState) > 0 {
_ = json.Unmarshal(run.ScriptState, &state)
}
if state.ScriptAnswers == nil {
state.ScriptAnswers = map[string][]string{}
}
if state.DimensionSelects == nil {
state.DimensionSelects = map[string]bool{}
}
return state
}
func runRenderContext(run model.SOPRun) scriptkit.RenderContext {
input := map[string]interface{}{}
_ = json.Unmarshal(run.Input, &input)
derived := map[string]interface{}{}
_ = json.Unmarshal(run.Derived, &derived)
answers := map[string]interface{}{}
_ = json.Unmarshal(run.Answers, &answers)
return scriptkit.RenderContext{Input: input, Derived: derived, Form: answers}
}
// runWeights deterministically recomputes the current dimension weights.
func runWeights(run model.SOPRun, pkg *scriptkit.Package) map[uint64]int {
ctx := runRenderContext(run)
return pkg.RecomputeWeights(pkg.InitialWeights(ctx), runScriptState(run), ctx)
}
// persistRunWeights rewrites the materialized run_dimensions rows inside the
// current transaction.
func persistRunWeights(tx *gorm.DB, run model.SOPRun, pkg *scriptkit.Package, weights map[uint64]int) error {
if err := tx.Where("tenant_id = ? AND run_id = ?", run.TenantID, run.ID).Delete(&model.RunDimension{}).Error; err != nil {
return err
}
for _, value := range pkg.Values {
row := model.RunDimension{TenantID: run.TenantID, RunID: run.ID, PackageID: pkg.Package.ID, DimensionID: value.DimensionID, ValueKey: value.ValueKey, Weight: weights[value.ID]}
if err := tx.Create(&row).Error; err != nil {
return err
}
}
return nil
}
// buildStageView renders the stage bound to a node for the current run.
func buildStageView(db *gorm.DB, run model.SOPRun, node model.SOPNode) (*scriptkit.StageView, error) {
config, err := parseStageConfig(node)
if err != nil {
return nil, err
}
pkg, err := loadRunPackage(db, run)
if err != nil {
return nil, err
}
stage, ok := pkg.StageByKey(config.StageKey)
if !ok {
return nil, errors.New("当前节点关联的阶段不存在")
}
formFields, err := loadStageFormFields(db, node, run.TenantID, config)
if err != nil {
return nil, err
}
view := pkg.BuildStageView(stage, runWeights(run, pkg), runRenderContext(run), runScriptState(run), formFields)
return &view, nil
}
func loadStageFormFields(db *gorm.DB, node model.SOPNode, tenantID uint64, config stageNodeConfig) ([]scriptkit.FormFieldView, error) {
if len(config.FieldKeys) == 0 {
return []scriptkit.FormFieldView{}, nil
}
var fields []model.ScenarioField
if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPID, tenantID, config.FieldKeys).Find(&fields).Error; err != nil {
return nil, err
}
byKey := map[string]model.ScenarioField{}
for _, field := range fields {
byKey[field.FieldKey] = field
}
views := make([]scriptkit.FormFieldView, 0, len(config.FieldKeys))
for _, key := range config.FieldKeys {
field, ok := byKey[key]
if !ok {
continue
}
required := field.Required || containsString(config.RequiredFieldKeys, key)
views = append(views, scriptkit.FormFieldView{Key: field.FieldKey, Name: field.FieldName, Type: field.FieldType, Required: required, Options: json.RawMessage(field.Options), Validation: json.RawMessage(field.Validation)})
}
return views, nil
}
// applyStageAnswer records one or more stage answers (script answers, dimension
// selects, form fields) in one batch and recomputes the run dimension weights
// once. The run stays on the same node.
func applyStageAnswer(tx *gorm.DB, run *model.SOPRun, node model.SOPNode, input stageAnswerInput) error {
pkg, err := loadRunPackage(tx, *run)
if err != nil {
return err
}
config, err := parseStageConfig(node)
if err != nil {
return err
}
stage, ok := pkg.StageByKey(config.StageKey)
if !ok {
return errors.New("当前节点关联的阶段不存在")
}
state := runScriptState(*run)
answers := map[string]interface{}{}
if len(run.Answers) > 0 {
_ = json.Unmarshal(run.Answers, &answers)
}
if answers == nil {
answers = map[string]interface{}{}
}
ctx := runRenderContext(*run)
base := pkg.InitialWeights(ctx)
changed := false
items := input.ScriptAnswers
if len(items) == 0 && input.ScriptKey != "" {
items = []stageScriptAnswerInput{{ScriptKey: input.ScriptKey, OptionKeys: input.OptionKeys, Value: input.Value}}
}
// 筛选题先处理:它按当前候选症状整体升维/归零,顺序无关但语义上应最先。
for _, item := range items {
if item.ScriptKey == "" {
continue
}
script, found := pkg.ScriptByKey(item.ScriptKey)
if !found || script.StageID != stage.ID {
return fmt.Errorf("话术 %s 不属于当前阶段", item.ScriptKey)
}
if script.ScriptType == "screen" {
if err := recordStageScriptAnswer(pkg, script, &state, answers, ctx, base, item); err != nil {
return err
}
changed = true
}
}
for _, item := range items {
if item.ScriptKey == "" {
continue
}
script, found := pkg.ScriptByKey(item.ScriptKey)
if !found || script.StageID != stage.ID {
return fmt.Errorf("话术 %s 不属于当前阶段", item.ScriptKey)
}
if script.ScriptType == "screen" {
continue
}
if err := recordStageScriptAnswer(pkg, script, &state, answers, ctx, base, item); err != nil {
return err
}
changed = true
}
if input.DimensionValueKey != "" && input.Selected != nil {
if _, ok := pkg.ValueByKey(input.DimensionValueKey); !ok {
return fmt.Errorf("维度值 %s 不存在", input.DimensionValueKey)
}
state.DimensionSelects[input.DimensionValueKey] = *input.Selected
changed = true
}
if len(input.DimensionSelects) > 0 {
for key, selected := range input.DimensionSelects {
if _, ok := pkg.ValueByKey(key); !ok {
return fmt.Errorf("维度值 %s 不存在", key)
}
state.DimensionSelects[key] = selected
}
changed = true
}
if len(input.Answers) > 0 {
if len(config.FieldKeys) == 0 {
return errors.New("当前阶段没有内容补充表单")
}
fields := make([]model.ScenarioField, 0)
if err := tx.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", run.SOPID, run.TenantID).Find(&fields).Error; err != nil {
return err
}
formNode := model.SOPNode{Type: "form", Config: datatypes.JSON(mustJSON(config))}
if err := validateNodeAnswers(formNode, fields, input.Answers); err != nil {
return err
}
for key, value := range input.Answers {
answers[key] = value
}
changed = true
}
if changed {
answersRaw, _ := json.Marshal(answers)
stateRaw, _ := json.Marshal(state)
run.Answers = datatypes.JSON(answersRaw)
run.ScriptState = datatypes.JSON(stateRaw)
if err := tx.Model(run).Updates(map[string]interface{}{"answers": run.Answers, "script_state": run.ScriptState}).Error; err != nil {
return err
}
weights := runWeights(*run, pkg)
if err := persistRunWeights(tx, *run, pkg, weights); err != nil {
return err
}
payload, _ := json.Marshal(input)
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "answer", Payload: datatypes.JSON(payload)}).Error
}
return nil
}
// recordStageScriptAnswer applies one script answer to the in-memory state.
// Weights are not persisted here; the caller recomputes them once after the
// whole batch is applied.
func recordStageScriptAnswer(pkg *scriptkit.Package, script model.StageScript, state *scriptkit.ScriptState, answers map[string]interface{}, ctx scriptkit.RenderContext, base map[uint64]int, item stageScriptAnswerInput) error {
switch {
case item.OptionKeys != nil && script.ScriptType == "screen":
// 症状筛选题:多选。被选中的症状确认并进入细节,
// 未选中的候选症状统一归零,不再追问。
selected := map[uint64]bool{}
for _, key := range item.OptionKeys {
option, ok := pkg.OptionByKey(script, key)
if !ok {
return fmt.Errorf("话术 %s 的选项 %s 不存在", item.ScriptKey, key)
}
if option.TargetDimensionValueID != nil {
selected[*option.TargetDimensionValueID] = true
}
}
if script.CollectFieldKey != "" {
labels := make([]string, 0, len(item.OptionKeys))
for _, key := range item.OptionKeys {
if option, ok := pkg.OptionByKey(script, key); ok {
labels = append(labels, option.Label)
}
}
answers[script.CollectFieldKey] = strings.Join(labels, "、")
}
state.ScriptAnswers[item.ScriptKey] = item.OptionKeys
dimensionID := screenScriptDimension(pkg, script)
if dimensionID != 0 {
current := pkg.RecomputeWeights(base, *state, ctx)
for _, value := range pkg.Values {
if value.DimensionID == dimensionID && current[value.ID] > 0 {
state.DimensionSelects[value.ValueKey] = selected[value.ID]
}
}
}
case item.OptionKeys != nil:
if script.ScriptType != "confirm" && script.ScriptType != "choice" && script.ScriptType != "info" {
return fmt.Errorf("话术 %s 不接受选项回答", item.ScriptKey)
}
// 选择题支持多选和全部取消;确认题至少需要一个选项。
if len(item.OptionKeys) == 0 && script.ScriptType != "choice" {
return errors.New("回答缺少选项")
}
for _, key := range item.OptionKeys {
if _, ok := pkg.OptionByKey(script, key); !ok {
return fmt.Errorf("话术 %s 的选项 %s 不存在", item.ScriptKey, key)
}
}
if script.CollectFieldKey != "" {
labels := make([]string, 0, len(item.OptionKeys))
for _, key := range item.OptionKeys {
if option, ok := pkg.OptionByKey(script, key); ok {
labels = append(labels, option.Label)
}
}
answers[script.CollectFieldKey] = strings.Join(labels, "、")
}
state.ScriptAnswers[item.ScriptKey] = item.OptionKeys
case item.Value != "":
if script.CollectFieldKey == "" {
return fmt.Errorf("话术 %s 不采集信息", item.ScriptKey)
}
answers[script.CollectFieldKey] = item.Value
state.ScriptAnswers[item.ScriptKey] = []string{item.Value}
default:
return errors.New("回答缺少选项或内容")
}
return nil
}
// screenScriptDimension returns the dimension targeted by a screening script's
// options, or zero when the script has no dimension targets.
func screenScriptDimension(pkg *scriptkit.Package, script model.StageScript) uint64 {
for _, option := range pkg.Options {
if option.ScriptID != script.ID || option.TargetDimensionValueID == nil {
continue
}
for _, value := range pkg.Values {
if value.ID == *option.TargetDimensionValueID {
return value.DimensionID
}
}
}
return 0
}
func mustJSON(value interface{}) []byte {
raw, _ := json.Marshal(value)
return raw
}
// stageCanNext reports whether the current stage view allows advancing.
func stageCanNext(db *gorm.DB, run model.SOPRun, node model.SOPNode) (bool, error) {
view, err := buildStageView(db, run, node)
if err != nil {
return false, err
}
return view.CanNext, nil
}
// advanceFromStage moves the run to the next node when the stage is complete.
func advanceFromStage(tx *gorm.DB, run *model.SOPRun, node model.SOPNode) error {
view, err := buildStageView(tx, *run, node)
if err != nil {
return err
}
if !view.CanNext {
if view.CanNextReason != "" {
return errors.New(view.CanNextReason)
}
return errors.New("当前阶段还有必填内容未完成")
}
return advanceRunNode(tx, run)
}
// advanceRunNode moves the run to the next node matched by edges.
func advanceRunNode(tx *gorm.DB, run *model.SOPRun) error {
var edges []model.SOPEdge
if err := tx.Where("sop_id = ? AND source_node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).Order("priority, id").Find(&edges).Error; err != nil {
return err
}
context := runtimeContext(mapsFromRun(*run))
sortEdges(edges)
nextKey := ""
for _, edge := range edges {
matched, err := matchCondition(json.RawMessage(edge.Condition), context)
if err != nil {
return err
}
if matched {
nextKey = edge.TargetNodeKey
break
}
}
if nextKey == "" {
return errors.New("没有满足条件的下一节点")
}
var next model.SOPNode
if err := tx.Where("sop_id = ? AND node_key = ? AND tenant_id = ?", run.SOPID, nextKey, run.TenantID).First(&next).Error; err != nil {
return err
}
updates := map[string]interface{}{"current_node_key": nextKey}
if next.Type == "finish" || next.Type == "escalate" {
now := time.Now()
updates["status"] = "completed"
updates["completed_at"] = &now
updates["result"] = next.Type
run.Status, run.CompletedAt, run.Result = "completed", &now, next.Type
}
if err := tx.Model(run).Updates(updates).Error; err != nil {
return err
}
run.CurrentNodeKey = nextKey
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error; err != nil {
return err
}
if run.Status == "completed" {
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: nextKey, Action: "finish", Payload: datatypes.JSON([]byte(`{"source":"terminal_node"}`))}).Error
}
return nil
}
// backRunNode moves the run to the previous node via reverse edges.
func backRunNode(tx *gorm.DB, run *model.SOPRun) error {
var edges []model.SOPEdge
if err := tx.Where("sop_id = ? AND target_node_key = ? AND tenant_id = ?", run.SOPID, run.CurrentNodeKey, run.TenantID).Order("priority, id").Find(&edges).Error; err != nil {
return err
}
if len(edges) == 0 {
return errors.New("当前已经是第一步")
}
prevKey := edges[0].SourceNodeKey
if err := tx.Model(run).Update("current_node_key", prevKey).Error; err != nil {
return err
}
run.CurrentNodeKey = prevKey
return nil
}

View File

@@ -2,9 +2,9 @@ package run
import (
"encoding/json"
"fmt"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
"gorm.io/gorm"
)
@@ -42,7 +42,7 @@ type startPresentationConfig struct {
OpeningTemplate string `json:"opening_template"`
}
func loadStartPresentation(db *gorm.DB, node model.SOPNode, tenantID uint64, context map[string]interface{}) (*NodePresentation, error) {
func loadStartPresentation(db *gorm.DB, node model.SOPNode, tenantID uint64, context scriptkit.RenderContext) (*NodePresentation, error) {
var config startNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil {
return nil, err
@@ -68,14 +68,14 @@ func loadStartPresentation(db *gorm.DB, node model.SOPNode, tenantID uint64, con
return buildStartPresentation(*config.Presentation, byKey, context), nil
}
func buildStartPresentation(config startPresentationConfig, fields map[string]model.ScenarioField, context map[string]interface{}) *NodePresentation {
func buildStartPresentation(config startPresentationConfig, fields map[string]model.ScenarioField, context scriptkit.RenderContext) *NodePresentation {
view := &NodePresentation{Summary: []PresentationField{}, Items: []PresentationItem{}}
imageKeys := make(map[string]bool, len(config.ImageFieldKeys))
for _, key := range config.ImageFieldKeys {
imageKeys[key] = true
}
for _, key := range config.SummaryFieldKeys {
value, ok := lookupContextValue(context, "input."+key)
value, ok := context.Input[key]
if !ok || presentationValueEmpty(value) {
continue
}
@@ -84,7 +84,7 @@ func buildStartPresentation(config startPresentationConfig, fields map[string]mo
itemValues := make(map[string][]interface{}, len(config.ItemFieldKeys))
itemCount := 0
for _, key := range config.ItemFieldKeys {
value, _ := lookupContextValue(context, "input."+key)
value := context.Input[key]
values := presentationValues(value)
itemValues[key] = values
if len(values) > itemCount {
@@ -109,7 +109,7 @@ func buildStartPresentation(config startPresentationConfig, fields map[string]mo
if title == "" {
title = "开场话术"
}
view.Opening = &PresentationCopy{Title: title, Content: fmt.Sprint(renderKnowledgeValue(config.OpeningTemplate, context))}
view.Opening = &PresentationCopy{Title: title, Content: scriptkit.RenderTemplate(config.OpeningTemplate, context)}
}
return view
}

View File

@@ -4,6 +4,7 @@ import (
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
)
func TestBuildStartPresentation(t *testing.T) {
@@ -22,13 +23,13 @@ func TestBuildStartPresentation(t *testing.T) {
"product_names": {FieldKey: "product_names", FieldName: "订单商品名称"},
"product_ids": {FieldKey: "product_ids", FieldName: "订单商品 ID"},
}
context := runtimeContext(map[string]interface{}{
context := scriptkit.RenderContext{Input: map[string]interface{}{
"order_id": "ORDER-001",
"customer_name": "王女士",
"product_images": []interface{}{"https://img.example.com/a.jpg", "https://img.example.com/b.jpg"},
"product_names": []interface{}{"商品 A", "商品 B"},
"product_ids": []interface{}{"SKU-A", "SKU-B"},
}, nil, nil)
}, Derived: map[string]interface{}{}, Form: map[string]interface{}{}}
got := buildStartPresentation(config, fields, context)
if len(got.Summary) != 2 {

View File

@@ -69,9 +69,6 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe
expected[key] = containsString(config.RequiredFieldKeys, key)
}
default:
if node.Type == "knowledge" {
return validateKnowledgeNodeAnswers(node, fieldMap, answers)
}
if len(answers) > 0 {
return fmt.Errorf("当前节点不接受字段回答")
}
@@ -106,67 +103,6 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe
return nil
}
func validateKnowledgeNodeAnswers(node model.SOPNode, fields map[string]model.ScenarioField, answers map[string]interface{}) error {
var config knowledgeNodeConfig
if json.Unmarshal(node.Config, &config) != nil || config.KnowledgeCollection == nil {
if len(answers) > 0 {
return fmt.Errorf("当前节点不接受字段回答")
}
return nil
}
expected := map[string]bool{}
for _, key := range config.KnowledgeCollection.ContextFieldKeys {
expected[key] = fields[key].Required
}
for _, step := range config.KnowledgeCollection.Steps {
expected[step.FieldKey] = step.Required
}
for key := range answers {
if _, ok := expected[key]; !ok {
return fmt.Errorf("字段 %s 不属于当前节点", key)
}
}
for key, required := range expected {
value := answers[key]
if isEmptyValue(value) {
if required {
return fmt.Errorf("请填写%s", knowledgeAnswerName(config, fields, key))
}
continue
}
if field, ok := fields[key]; ok && !isKnowledgeStep(config, key) {
if err := validateFieldValue(field, value); err != nil {
return err
}
}
if isKnowledgeStep(config, key) {
if _, ok := stringValues(value); !ok {
return fmt.Errorf("%s必须选择有效选项", knowledgeAnswerName(config, fields, key))
}
}
}
return nil
}
func knowledgeAnswerName(config knowledgeNodeConfig, fields map[string]model.ScenarioField, key string) string {
if field, ok := fields[key]; ok {
return field.FieldName
}
for _, step := range config.KnowledgeCollection.Steps {
if step.FieldKey == key {
return step.Name
}
}
return key
}
func isKnowledgeStep(config knowledgeNodeConfig, key string) bool {
for _, step := range config.KnowledgeCollection.Steps {
if step.FieldKey == key {
return true
}
}
return false
}
func stringValues(value interface{}) ([]string, bool) {
switch typed := value.(type) {
case string:

99
internal/run/view.go Normal file
View File

@@ -0,0 +1,99 @@
package run
import (
"encoding/json"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/scriptkit"
"gorm.io/gorm"
)
// NodeView is the public view of the current SOP node.
type NodeView struct {
NodeKey string `json:"node_key"`
Type string `json:"type"`
Title string `json:"title"`
Content string `json:"content"`
Config json.RawMessage `json:"config,omitempty"`
Fields []PublicFieldView `json:"fields,omitempty"`
Presentation *NodePresentation `json:"presentation,omitempty"`
Stage *scriptkit.StageView `json:"stage,omitempty"`
}
// PublicFieldView mirrors a scenario field for the client.
type PublicFieldView 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"`
}
func nodeView(db *gorm.DB, run model.SOPRun, node model.SOPNode) (NodeView, error) {
view := NodeView{NodeKey: node.NodeKey, Type: node.Type, Title: node.Title, Content: node.Content, Config: json.RawMessage(node.Config)}
switch node.Type {
case "start":
input, derived, answers := mapsFromRun(run)
presentation, err := loadStartPresentation(db, node, run.TenantID, scriptkit.RenderContext{Input: input, Derived: derived, Form: answers})
if err != nil {
return view, err
}
view.Presentation = presentation
case "question", "choice", "form":
fields, err := loadPublicNodeFields(db, node, run.TenantID)
if err != nil {
return view, err
}
view.Fields = fields
case "stage":
stage, err := buildStageView(db, run, node)
if err != nil {
return view, err
}
view.Stage = stage
}
return view, nil
}
// mapsFromRun returns input, derived and form maps for legacy views.
func mapsFromRun(run model.SOPRun) (map[string]interface{}, map[string]interface{}, map[string]interface{}) {
input := map[string]interface{}{}
_ = json.Unmarshal(run.Input, &input)
derived := map[string]interface{}{}
_ = json.Unmarshal(run.Derived, &derived)
answers := map[string]interface{}{}
_ = json.Unmarshal(run.Answers, &answers)
return input, derived, answers
}
func loadPublicNodeFields(db *gorm.DB, node model.SOPNode, tenantID uint64) ([]PublicFieldView, error) {
var config answerNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil {
return nil, err
}
keys := config.FieldKeys
if config.FieldKey != "" {
keys = []string{config.FieldKey}
}
if len(keys) == 0 {
return nil, nil
}
var fields []model.ScenarioField
if err := db.Table("scenario_fields sf").Select("sf.*").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPID, tenantID, keys).Find(&fields).Error; err != nil {
return nil, err
}
byKey := make(map[string]model.ScenarioField, len(fields))
for _, field := range fields {
byKey[field.FieldKey] = field
}
views := make([]PublicFieldView, 0, len(keys))
for _, key := range keys {
field, ok := byKey[key]
if !ok {
continue
}
views = append(views, PublicFieldView{Key: field.FieldKey, Name: field.FieldName, Type: field.FieldType, Required: field.Required || config.Required || containsString(config.RequiredFieldKeys, key), Options: json.RawMessage(field.Options), Validation: json.RawMessage(field.Validation)})
}
return views, nil
}