update
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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(¤tNode).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(¤tNode).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
11
internal/run/helpers.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
462
internal/run/stage.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
99
internal/run/view.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user