feat: simplify SOP to immediate-effect configuration

This commit is contained in:
Eric 1549169735@qq.com
2026-08-18 16:27:02 +08:00
parent c5ab886b70
commit 8de48fb05e
150 changed files with 6764 additions and 1626 deletions

View File

@@ -1,6 +1,7 @@
package run
import (
"encoding/json"
"net/http"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
@@ -14,17 +15,17 @@ type DetailHeader struct {
model.SOPRun
SOPName string `json:"sop_name"`
ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
OperatorName string `json:"operator_name"`
}
type DetailEvent struct {
model.SOPRunEvent
NodeTitle string `json:"node_title"`
NodeType string `json:"node_type"`
NodeContent string `json:"node_content"`
NodeConfig datatypes.JSON `json:"-"`
Knowledge *KnowledgeView `json:"knowledge,omitempty" gorm:"-"`
NodeTitle string `json:"node_title"`
NodeType string `json:"node_type"`
NodeContent string `json:"node_content"`
NodeConfig datatypes.JSON `json:"-"`
Knowledge *KnowledgeView `json:"knowledge,omitempty" gorm:"-"`
Outputs []KnowledgeGroup `json:"outputs,omitempty" gorm:"-"`
}
type DetailFeedback struct {
@@ -40,8 +41,8 @@ func (h *Handler) Detail(c *gin.Context) {
}
var header DetailHeader
err := h.db.Table("sop_runs r").Select(
"r.*, s.name AS sop_name, sc.name AS scenario_name, sv.version, u.display_name AS operator_name",
).Joins("JOIN sops s ON s.id = r.sop_id").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN sop_versions sv ON sv.id = r.sop_version_id").Joins("JOIN users u ON u.id = r.operator_id").Where("r.id = ? AND r.tenant_id = ?", id, principal.TenantID).Scan(&header).Error
"r.*, s.name AS sop_name, sc.name AS scenario_name, u.display_name AS operator_name",
).Joins("JOIN sops s ON s.id = r.sop_id").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN users u ON u.id = r.operator_id").Where("r.id = ? AND r.tenant_id = ?", id, principal.TenantID).Scan(&header).Error
if err != nil || header.ID == 0 || !canViewRun(principal, header.SOPRun) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return
@@ -52,6 +53,14 @@ func (h *Handler) Detail(c *gin.Context) {
return
}
knowledgeCache := map[string]*KnowledgeView{}
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
@@ -61,9 +70,23 @@ func (h *Handler) Detail(c *gin.Context) {
events[i].Knowledge = cached
continue
}
knowledge, err := loadKnowledgeView(h.db, model.SOPNode{Type: events[i].NodeType, Config: events[i].NodeConfig}, principal.TenantID)
var config knowledgeNodeConfig
_ = json.Unmarshal(events[i].NodeConfig, &config)
if config.KnowledgeSelector != nil {
var node model.SOPNode
if err := h.db.Where("sop_version_id = ? AND node_key = ?", header.SOPVersionID, events[i].NodeKey).First(&node).Error; err == nil {
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
}
continue
}
knowledge, err := loadKnowledgeView(h.db, model.SOPNode{Type: events[i].NodeType, Config: events[i].NodeConfig}, principal.TenantID, answers)
if err != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录关联的知识卡版本不存在")
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录关联的历史知识内容不存在")
return
}
events[i].Knowledge = &knowledge

View File

@@ -66,7 +66,7 @@ func matchRule(rule map[string]interface{}, answers map[string]interface{}) (boo
if field == "" || operator == "" {
return false, fmt.Errorf("condition field and operator are required")
}
actual, exists := answers[field]
actual, exists := lookupContextValue(answers, field)
expected := rule["value"]
switch operator {
case "exists":
@@ -105,6 +105,35 @@ func matchRule(rule map[string]interface{}, answers map[string]interface{}) (boo
}
}
// lookupContextValue accepts the public namespaced form (input.foo/derived.foo)
// and the legacy bare form used by SOP edge conditions.
func lookupContextValue(values map[string]interface{}, field string) (interface{}, bool) {
if value, ok := values[field]; ok {
return value, true
}
for _, prefix := range []string{"input.", "derived.", "form."} {
if strings.HasPrefix(field, prefix) {
key := strings.TrimPrefix(field, prefix)
if namespace, ok := values[strings.TrimSuffix(prefix, ".")].(map[string]interface{}); ok {
value, exists := namespace[key]
return value, exists
}
value, ok := values[key]
return value, ok
}
}
return nil, false
}
func runtimeContext(input, derived, form map[string]interface{}) map[string]interface{} {
context := mergeValues(input, derived)
context = mergeValues(context, form)
context["input"] = input
context["derived"] = derived
context["form"] = form
return context
}
func normalizeValue(value interface{}) interface{} {
switch typed := value.(type) {
case json.Number:

View File

@@ -0,0 +1,50 @@
package run
import (
"reflect"
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/datatypes"
)
func TestGenericRuntimeSupportsDifferentDomains(t *testing.T) {
tests := []struct {
name string
fields []model.ScenarioField
input map[string]interface{}
rules []model.ScenarioRule
wantInput map[string]interface{}
wantOutput interface{}
}{
{
name: "pet order",
fields: []model.ScenarioField{{FieldKey: "tags", SourcePath: "order.items[*].symptom_tags[*]"}},
input: map[string]interface{}{"order": map[string]interface{}{"items": []interface{}{map[string]interface{}{"symptom_tags": []interface{}{"soft_stool"}}}}},
rules: []model.ScenarioRule{{RuleKey: "pet", Condition: datatypes.JSON([]byte(`{"field":"input.tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched","value_from":"input.tags"}]`))}},
wantInput: map[string]interface{}{"tags": []interface{}{"soft_stool"}}, wantOutput: []interface{}{"soft_stool"},
},
{
name: "course recommendation",
fields: []model.ScenarioField{{FieldKey: "goals", SourcePath: "learner.goals[*]"}},
input: map[string]interface{}{"learner": map[string]interface{}{"goals": []interface{}{"presentation"}}},
rules: []model.ScenarioRule{{RuleKey: "course", Condition: datatypes.JSON([]byte(`{"field":"input.goals","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched","value_from":"input.goals"}]`))}},
wantInput: map[string]interface{}{"goals": []interface{}{"presentation"}}, wantOutput: []interface{}{"presentation"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mapped := mapScenarioInput(test.fields, test.input)
if !reflect.DeepEqual(mapped, test.wantInput) {
t.Fatalf("mapped=%#v", mapped)
}
derived, _, err := applyScenarioRules(test.rules, mapped)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(derived["matched"], test.wantOutput) {
t.Fatalf("derived=%#v", derived)
}
})
}
}

View File

@@ -13,7 +13,9 @@ 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/resultcontract"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -27,7 +29,6 @@ type listItem struct {
model.SOPRun
SOPName string `json:"sop_name"`
ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
OperatorName string `json:"operator_name"`
}
@@ -40,7 +41,7 @@ func NewHandler(db *gorm.DB) *Handler {
return &Handler{db: db}
}
func (h *Handler) PublishedSOPs(c *gin.Context) {
func (h *Handler) AvailableSOPs(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
type item struct {
ID uint64 `json:"id"`
@@ -48,14 +49,13 @@ func (h *Handler) PublishedSOPs(c *gin.Context) {
Description string `json:"description"`
ScenarioID uint64 `json:"scenario_id"`
ScenarioName string `json:"scenario_name"`
Version int `json:"version"`
}
items := make([]item, 0)
query := h.db.Table("sops s").Select("s.id, s.name, s.description, s.scenario_id, sc.name AS scenario_name, sv.version").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id AND sv.status = ?", "published")
query := h.db.Table("sops s").Select("s.id, s.name, s.description, s.scenario_id, sc.name AS scenario_name").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN sop_versions sv ON sv.sop_id = s.id AND sv.status = ?", "published")
query = access.ScopeScenarios(query, p, "sc")
err := query.Where("s.tenant_id = ? AND s.status = ?", p.TenantID, "published").Order("s.updated_at DESC").Scan(&items).Error
if err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可执行 SOP 失败")
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询可 SOP 失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
@@ -64,27 +64,90 @@ func (h *Handler) PublishedSOPs(c *gin.Context) {
func (h *Handler) Start(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
var input struct {
SOPID uint64 `json:"sop_id" binding:"required"`
SOPID uint64 `json:"sop_id" binding:"required"`
Input map[string]interface{} `json:"input"`
ExternalRef string `json:"external_ref"`
InitialValues map[string]interface{} `json:"initial_values"`
InitialAnswers map[string]interface{} `json:"initial_answers"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要执行的 SOP")
return
}
if !access.CanViewSOP(h.db, p, input.SOPID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的已发布 SOP")
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的 SOP")
return
}
if err := validateExternalRef(input.ExternalRef); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_EXTERNAL_REF", err.Error())
return
}
if input.ExternalRef != "" {
var existing model.SOPRun
if err := h.db.Where("tenant_id = ? AND sop_id = ? AND external_ref = ?", p.TenantID, input.SOPID, input.ExternalRef).First(&existing).Error; err == nil {
h.respondRun(c, existing)
return
}
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", input.SOPID, p.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的已发布版本")
response.Error(c, http.StatusNotFound, "NOT_FOUND", "没有可执行的 SOP")
return
}
run := model.SOPRun{TenantID: p.TenantID, SOPID: input.SOPID, SOPVersionID: version.ID, OperatorID: p.UserID, CurrentNodeKey: version.StartNodeKey, Status: "running", Answers: datatypes.JSON([]byte(`{}`)), Result: "", StartedAt: time.Now()}
err := h.db.Transaction(func(tx *gorm.DB) error {
fields := make([]model.ScenarioField, 0)
if err := h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", input.SOPID, p.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
return
}
var sopItem model.SOP
if err := h.db.Where("id = ? AND tenant_id = ?", input.SOPID, p.TenantID).First(&sopItem).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
return
}
if input.Input == nil {
input.Input = map[string]interface{}{}
}
if input.InitialValues == nil {
input.InitialValues = input.InitialAnswers
}
if input.InitialValues == nil {
input.InitialValues = map[string]interface{}{}
}
normalizedInput := mergeValues(mapScenarioInput(fields, input.Input), input.InitialValues)
if err := validateInitialAnswers(fields, normalizedInput); err != nil {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_INITIAL_ANSWERS", err.Error())
return
}
initialAnswers, marshalErr := json.Marshal(normalizedInput)
if marshalErr != nil {
response.Error(c, http.StatusBadRequest, "INVALID_INITIAL_ANSWERS", "传入字段格式不正确")
return
}
derived, matchedRules, err := deriveForScenario(h.db, p.TenantID, sopItem.ScenarioID, normalizedInput)
if err != nil {
response.Error(c, http.StatusUnprocessableEntity, "RULE_EVALUATION_FAILED", err.Error())
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, SOPVersionID: version.ID, OperatorID: p.UserID, ExternalRef: externalRef, CurrentNodeKey: version.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()}
err = h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&run).Error; err != nil {
return err
}
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON([]byte(`{}`))}).Error
payload, err := json.Marshal(gin.H{"source": "scenario_input", "mapped_field_keys": sortedKeys(normalizedInput), "matched_rule_keys": matchedRules})
if err != nil {
return err
}
return tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON(payload)}).Error
})
if err != nil {
response.Error(c, http.StatusInternalServerError, "START_FAILED", "启动 SOP 失败")
@@ -118,7 +181,6 @@ func (h *Handler) List(c *gin.Context) {
query := scopeRuns(h.db.Table("sop_runs r").
Joins("JOIN sops s ON s.id = r.sop_id").
Joins("JOIN scenarios sc ON sc.id = s.scenario_id").
Joins("JOIN sop_versions sv ON sv.id = r.sop_version_id").
Joins("JOIN users u ON u.id = r.operator_id"), p, "r")
if status := c.Query("status"); status != "" {
query = query.Where("r.status = ?", status)
@@ -144,7 +206,10 @@ func (h *Handler) List(c *gin.Context) {
return
}
items := make([]listItem, 0)
if err := query.Select("r.*, s.name AS sop_name, sc.name AS scenario_name, sv.version, u.display_name AS operator_name").Order("r.created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil {
// The run payload contains several large JSON snapshots. The history list only
// needs scalar metadata; excluding blobs keeps MySQL's sort buffer bounded.
listColumns := "r.id, r.created_at, r.updated_at, r.tenant_id, r.sop_id, r.sop_version_id, r.operator_id, r.external_ref, r.current_node_key, r.status, r.result, r.started_at, r.completed_at, s.name AS sop_name, sc.name AS scenario_name, u.display_name AS operator_name"
if err := query.Select(listColumns).Order("r.created_at DESC, r.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
return
}
@@ -220,6 +285,9 @@ 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_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil {
return err
@@ -283,6 +351,15 @@ func sortEdges(edges []model.SOPEdge) {
})
}
func sortedKeys(values map[string]interface{}) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func defaultCondition(raw []byte) bool {
if len(raw) == 0 {
return true
@@ -302,10 +379,11 @@ func (h *Handler) Finish(c *gin.Context) {
return
}
var input struct {
Result string `json:"result" binding:"required,oneof=manual"`
Result string `json:"result"`
FinalResult json.RawMessage `json:"final_result"`
}
if err := c.ShouldBindJSON(&input); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择执行结果")
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "执行结果格式不正确")
return
}
var run model.SOPRun
@@ -317,20 +395,51 @@ func (h *Handler) Finish(c *gin.Context) {
if !canOperateRun(p, run) {
return gorm.ErrRecordNotFound
}
if run.Status != "running" {
if run.Status != "running" && run.Status != "completed" {
return runCompletedErr
}
now := time.Now()
payload, _ := json.Marshal(input)
if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: id, NodeKey: run.CurrentNodeKey, Action: "finish", Payload: datatypes.JSON(payload)}).Error; err != nil {
finalResult, err := parseFinalResult(input.FinalResult)
if err != nil {
return err
}
if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now}).Error; err != nil {
var scenario model.Scenario
if err := tx.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 err
}
schema, err := resultcontract.ParseAndValidate(scenario.ResultSchema)
if err != nil {
return err
}
if err := resultcontract.ValidateResult(schema, finalResult); err != nil {
return err
}
finalRaw, _ := json.Marshal(finalResult)
if input.Result == "" {
input.Result = run.Result
if input.Result == "" {
input.Result = "manual"
}
}
now := time.Now()
completedAt := run.CompletedAt
if completedAt == nil {
completedAt = &now
}
payload, _ := json.Marshal(input)
action := "finish"
if run.Status == "completed" {
action = "final_result"
}
if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: id, NodeKey: run.CurrentNodeKey, Action: action, Payload: datatypes.JSON(payload)}).Error; err != nil {
return err
}
if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "final_result": datatypes.JSON(finalRaw), "completed_at": completedAt}).Error; err != nil {
return err
}
run.Status = "completed"
run.Result = input.Result
run.CompletedAt = &now
run.FinalResult = datatypes.JSON(finalRaw)
run.CompletedAt = completedAt
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -342,7 +451,7 @@ func (h *Handler) Finish(c *gin.Context) {
return
}
if err != nil {
response.Error(c, http.StatusInternalServerError, "FINISH_FAILED", "结束执行失败")
response.Error(c, http.StatusUnprocessableEntity, "FINISH_FAILED", err.Error())
return
}
_ = audit.Record(h.db, p, "finish", "sop_run", id, input)
@@ -387,17 +496,39 @@ func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
return
}
nodeView, err := h.nodeView(h.db, node, item.TenantID)
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)
if err != nil {
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的知识卡版本不存在")
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的历史知识内容不存在")
return
}
outputs, err := buildScenarioOutputs(h.db, item, nodeView.Outputs)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败")
return
}
if raw, marshalErr := json.Marshal(outputs); marshalErr == nil {
item.Outputs = datatypes.JSON(raw)
_ = h.db.Model(&model.SOPRun{}).Where("id = ?", item.ID).Update("outputs", item.Outputs).Error
}
fields := make([]model.ScenarioField, 0)
if err := h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", item.SOPID, item.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
return
}
response.OK(c, gin.H{"run": item, "node": nodeView, "fields": fields})
var scenario model.Scenario
if err := h.db.Table("scenarios sc").Select("sc.*").Joins("JOIN sops s ON s.scenario_id = sc.id").Where("s.id = ? AND sc.tenant_id = ?", item.SOPID, item.TenantID).First(&scenario).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景结果格式失败")
return
}
response.OK(c, gin.H{"run": item, "node": nodeView, "fields": fields, "outputs": outputs, "result_schema": scenario.ResultSchema})
}
func runID(c *gin.Context) (uint64, bool) {

View File

@@ -2,37 +2,173 @@ package run
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
knowledgecontent "git.iwork-ai.com/xdc/iqudo-top1/internal/knowledge"
"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 KnowledgeView struct {
CardID uint64 `json:"card_id"`
CardVersionID uint64 `json:"card_version_id"`
Version int `json:"version"`
Title string `json:"title"`
StandardCopy string `json:"standard_copy"`
ForbiddenCopy string `json:"forbidden_copy"`
RiskNote string `json:"risk_note"`
CardID uint64 `json:"card_id"`
CardVersionID uint64 `json:"card_version_id"`
Version int `json:"version"`
Title string `json:"title"`
StandardCopy string `json:"standard_copy"`
ForbiddenCopy string `json:"forbidden_copy"`
RiskNote string `json:"risk_note"`
Copies []KnowledgeCopy `json:"copies"`
}
type KnowledgeCopy struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
}
type NodeView struct {
model.SOPNode
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
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"`
Knowledge *KnowledgeView `json:"knowledge,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 {
KnowledgeCardID uint64 `json:"knowledge_card_id"`
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
KnowledgeCardID uint64 `json:"knowledge_card_id"`
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
KnowledgeSelector *knowledgeSelector `json:"knowledge_selector"`
KnowledgeCollection *knowledgeCollectionConfig `json:"knowledge_collection"`
}
func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (NodeView, error) {
view := NodeView{SOPNode: node}
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
}
knowledge, err := loadKnowledgeView(db, node, tenantID)
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
}
knowledge, err := loadKnowledgeView(db, node, tenantID, answers)
if err != nil {
return view, err
}
@@ -40,7 +176,366 @@ func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (No
return view, nil
}
func loadKnowledgeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (KnowledgeView, error) {
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").Joins("JOIN sop_versions sv ON sv.sop_id = s.id").Where("sv.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPVersionID, 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").Joins("JOIN sop_versions sv ON sv.sop_id = s.id").Where("sv.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPVersionID, 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 sop_versions sv ON sv.id = n.sop_version_id").Joins("JOIN sops s ON s.id = sv.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
}
func loadKnowledgeView(db *gorm.DB, node model.SOPNode, tenantID uint64, answers map[string]interface{}) (KnowledgeView, error) {
var config knowledgeNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil {
return KnowledgeView{}, err
@@ -61,16 +556,21 @@ func loadKnowledgeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (Knowle
if err := query.First(&version).Error; err != nil {
return KnowledgeView{}, err
}
var content struct {
StandardCopy string `json:"standard_copy"`
ForbiddenCopy string `json:"forbidden_copy"`
RiskNote string `json:"risk_note"`
}
if err := json.Unmarshal(version.Content, &content); err != nil {
content, err := knowledgecontent.ParseContent(version.Content)
if err != nil {
return KnowledgeView{}, err
}
rendered := knowledgecontent.Render(content, answers)
copies := make([]KnowledgeCopy, 0, len(rendered))
for _, copy := range rendered {
copies = append(copies, KnowledgeCopy{ID: copy.ID, Title: copy.Title, Content: copy.Content})
}
standardCopy := ""
if len(copies) > 0 {
standardCopy = copies[0].Content
}
return KnowledgeView{
CardID: version.KnowledgeCardID, CardVersionID: version.ID, Version: version.Version, Title: version.Title,
StandardCopy: content.StandardCopy, ForbiddenCopy: content.ForbiddenCopy, RiskNote: content.RiskNote,
StandardCopy: standardCopy, ForbiddenCopy: content.ForbiddenCopy, RiskNote: content.RiskNote, Copies: copies,
}, nil
}

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
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")
}
}

93
internal/run/mapping.go Normal file
View File

@@ -0,0 +1,93 @@
package run
import (
"fmt"
"regexp"
"strconv"
"strings"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
var pathTokenPattern = regexp.MustCompile(`^([A-Za-z][A-Za-z0-9_]*)(?:\[(\d+|\*)\])?$`)
func mapScenarioInput(fields []model.ScenarioField, input map[string]interface{}) map[string]interface{} {
mapped := make(map[string]interface{})
for _, field := range fields {
if field.SourcePath == "" {
continue
}
if value, ok := extractPath(input, field.SourcePath); ok {
mapped[field.FieldKey] = value
}
}
return mapped
}
func extractPath(root map[string]interface{}, path string) (interface{}, bool) {
parts := strings.Split(path, ".")
return walkPath(root, parts)
}
func walkPath(current interface{}, parts []string) (interface{}, bool) {
if len(parts) == 0 {
return current, true
}
match := pathTokenPattern.FindStringSubmatch(parts[0])
if match == nil {
return nil, false
}
object, ok := current.(map[string]interface{})
if !ok {
return nil, false
}
value, ok := object[match[1]]
if !ok {
return nil, false
}
if match[2] == "" {
return walkPath(value, parts[1:])
}
items, ok := value.([]interface{})
if !ok {
return nil, false
}
if match[2] == "*" {
values := make([]interface{}, 0)
for _, item := range items {
resolved, found := walkPath(item, parts[1:])
if !found {
continue
}
if nested, ok := resolved.([]interface{}); ok {
values = append(values, nested...)
} else {
values = append(values, resolved)
}
}
return values, len(values) > 0
}
index, err := strconv.Atoi(match[2])
if err != nil || index < 0 || index >= len(items) {
return nil, false
}
return walkPath(items[index], parts[1:])
}
func mergeValues(base map[string]interface{}, overrides map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{}, len(base)+len(overrides))
for key, value := range base {
result[key] = value
}
for key, value := range overrides {
result[key] = value
}
return result
}
func validateExternalRef(value string) error {
if len(value) > 191 {
return fmt.Errorf("external_ref 不能超过191个字符")
}
return nil
}

View File

@@ -0,0 +1,24 @@
package run
import (
"reflect"
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
func TestMapScenarioInputSupportsArrays(t *testing.T) {
fields := []model.ScenarioField{{FieldKey: "customer_name", SourcePath: "customer.name"}, {FieldKey: "product_ids", SourcePath: "order.items[*].product_id"}}
input := map[string]interface{}{"customer": map[string]interface{}{"name": "王女士"}, "order": map[string]interface{}{"items": []interface{}{map[string]interface{}{"product_id": "P1"}, map[string]interface{}{"product_id": "P2"}}}}
want := map[string]interface{}{"customer_name": "王女士", "product_ids": []interface{}{"P1", "P2"}}
if got := mapScenarioInput(fields, input); !reflect.DeepEqual(got, want) {
t.Fatalf("mapped input = %#v, want %#v", got, want)
}
}
func TestMergeValuesUsesExplicitValues(t *testing.T) {
got := mergeValues(map[string]interface{}{"name": "mapped"}, map[string]interface{}{"name": "explicit"})
if got["name"] != "explicit" {
t.Fatalf("name = %v", got["name"])
}
}

View File

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

101
internal/run/outputs.go Normal file
View File

@@ -0,0 +1,101 @@
package run
import (
"encoding/json"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm"
)
type outputSchema struct {
Fields []outputField `json:"fields"`
}
type outputField struct {
Key string `json:"key"`
Name string `json:"name"`
Display string `json:"display"`
Type string `json:"type"`
Source string `json:"source"`
SourceField string `json:"source_field"`
Default interface{} `json:"default"`
}
type OutputView struct {
Key string `json:"key"`
Name string `json:"name"`
Type string `json:"type"`
Source string `json:"source"`
Value interface{} `json:"value"`
}
func buildScenarioOutputs(db *gorm.DB, run model.SOPRun, knowledge []KnowledgeGroup) ([]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
}
input := map[string]interface{}{}
derived := map[string]interface{}{}
answers := map[string]interface{}{}
_ = 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)
}
func buildOutputViews(raw []byte, input, derived, answers map[string]interface{}, knowledge []KnowledgeGroup, status, result string) ([]OutputView, error) {
var schema outputSchema
if err := json.Unmarshal(raw, &schema); err != nil {
return nil, err
}
views := make([]OutputView, 0, len(schema.Fields))
for _, field := range schema.Fields {
sourceField := field.SourceField
var value interface{}
var ok bool
switch field.Source {
case "derived":
if sourceField == "" {
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
}
value, ok = answers[sourceField]
case "system":
if sourceField == "status" {
value, ok = status, true
} else if sourceField == "result" {
value, ok = result, true
}
default:
if sourceField == "" {
sourceField = field.Key
}
value, ok = input[sourceField]
}
if !ok {
value = field.Default
}
name := field.Name
if name == "" {
name = field.Display
}
if name == "" {
name = field.Key
}
views = append(views, OutputView{Key: field.Key, Name: name, Type: field.Type, Source: field.Source, Value: value})
}
return views, nil
}

View File

@@ -0,0 +1,21 @@
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)
}
}

62
internal/run/preview.go Normal file
View File

@@ -0,0 +1,62 @@
package run
import (
"net/http"
"strconv"
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
"github.com/gin-gonic/gin"
)
func (h *Handler) PreviewScenario(c *gin.Context) {
p, _ := auth.PrincipalFromContext(c)
scenarioID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || scenarioID == 0 || !access.CanViewScenario(h.db, p, scenarioID) {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
return
}
var body struct {
Input map[string]interface{} `json:"input"`
InitialValues map[string]interface{} `json:"initial_values"`
Selector knowledgeSelector `json:"knowledge_selector"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "预览参数格式不正确")
return
}
var scenario model.Scenario
if err := h.db.Where("id = ? AND tenant_id = ?", scenarioID, p.TenantID).First(&scenario).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "场景不存在")
return
}
var fields []model.ScenarioField
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", scenarioID, p.TenantID).Order("sort_order,id").Find(&fields).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景输入失败")
return
}
mapped := mergeValues(mapScenarioInput(fields, body.Input), body.InitialValues)
if err := validateInitialAnswers(fields, mapped); err != nil {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_INPUT", err.Error())
return
}
derived, matchedRules, err := deriveForScenario(h.db, p.TenantID, scenarioID, mapped)
if err != nil {
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", "")
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})
}

458
internal/run/public.go Normal file
View File

@@ -0,0 +1,458 @@
package run
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"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"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
errPublicRunCompleted = errors.New("执行记录已经结束")
errPublicNodeChanged = errors.New("当前步骤已经变化")
errPublicInputNeeded = errors.New("当前节点需要提交表单")
)
func (h *Handler) PublicStart(c *gin.Context) {
var body struct {
PublicKey string `json:"public_key" binding:"required"`
SOPID uint64 `json:"sop_id"`
Input map[string]interface{} `json:"input"`
InitialValues map[string]interface{} `json:"initial_values"`
ExternalRef string `json:"external_ref"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "公开场景参数不完整")
return
}
var scenario model.Scenario
if err := h.db.Where("scenario_key = ? AND public_key = ? AND status <> ?", c.Param("scenarioKey"), body.PublicKey, "archived").First(&scenario).Error; err != nil {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "公开场景不存在")
return
}
query := h.db.Where("scenario_id = ? AND tenant_id = ? AND status = ?", scenario.ID, scenario.TenantID, "published")
if body.SOPID != 0 {
query = query.Where("id = ?", body.SOPID)
}
var sop model.SOP
if err := query.Order("updated_at DESC").First(&sop).Error; err != nil {
response.Error(c, http.StatusNotFound, "SOP_NOT_FOUND", "场景没有可执行的 SOP")
return
}
if body.ExternalRef != "" {
var existing model.SOPRun
if err := h.db.Where("tenant_id = ? AND sop_id = ? AND external_ref = ?", scenario.TenantID, sop.ID, body.ExternalRef).First(&existing).Error; err == nil {
token := uuid.NewString() + uuid.NewString()
if err := h.db.Create(&model.PublicRunSession{TenantID: scenario.TenantID, RunID: existing.ID, TokenHash: publicTokenHash(token), ExpiresAt: time.Now().Add(24 * time.Hour)}).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "SESSION_FAILED", "创建 SDK 会话失败")
return
}
h.respondPublicRun(c, existing, token)
return
}
}
var version model.SOPVersion
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", sop.ID, scenario.TenantID, "published").Order("version DESC").First(&version).Error; err != nil {
response.Error(c, http.StatusNotFound, "SOP_NOT_FOUND", "场景没有可执行的 SOP")
return
}
fields := make([]model.ScenarioField, 0)
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", scenario.ID, scenario.TenantID).Order("sort_order, id").Find(&fields).Error; err != nil {
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景契约失败")
return
}
normalized := mergeValues(mapScenarioInput(fields, body.Input), body.InitialValues)
if err := validateInitialAnswers(fields, normalized); err != nil {
response.Error(c, http.StatusUnprocessableEntity, "INVALID_INPUT", err.Error())
return
}
raw, _ := json.Marshal(normalized)
derived, matchedRules, err := deriveForScenario(h.db, scenario.TenantID, scenario.ID, normalized)
if err != nil {
response.Error(c, http.StatusUnprocessableEntity, "RULE_EVALUATION_FAILED", err.Error())
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, SOPVersionID: version.ID, OperatorID: scenario.CreatedBy, ExternalRef: externalRef, CurrentNodeKey: version.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()}
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 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
}
payload, _ := json.Marshal(gin.H{"source": "public_sdk", "mapped_field_keys": sortedKeys(normalized), "matched_rule_keys": matchedRules})
return tx.Create(&model.SOPRunEvent{TenantID: scenario.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "start", Payload: datatypes.JSON(payload)}).Error
})
if err != nil {
if body.ExternalRef != "" {
var existing model.SOPRun
if findErr := h.db.Where("tenant_id = ? AND sop_id = ? AND external_ref = ?", scenario.TenantID, sop.ID, body.ExternalRef).First(&existing).Error; findErr == nil {
token = uuid.NewString() + uuid.NewString()
if sessionErr := h.db.Create(&model.PublicRunSession{TenantID: scenario.TenantID, RunID: existing.ID, TokenHash: publicTokenHash(token), ExpiresAt: time.Now().Add(24 * time.Hour)}).Error; sessionErr == nil {
h.respondPublicRun(c, existing, token)
return
}
}
}
response.Error(c, http.StatusInternalServerError, "START_FAILED", "启动公开场景失败")
return
}
h.respondPublicRun(c, run, token)
}
func (h *Handler) PublicCurrent(c *gin.Context) {
session, run, ok := h.publicSession(c)
if !ok {
return
}
_ = session
h.respondPublicRun(c, run, "")
}
func (h *Handler) PublicSubmit(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
return
}
var body struct {
NodeKey string `json:"node_key"`
Answers map[string]interface{} `json:"answers"`
}
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_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
return err
}
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
}
if err := validateNodeAnswers(node, fields, body.Answers); err != nil {
return err
}
answers := map[string]interface{}{}
_ = json.Unmarshal(run.Answers, &answers)
for key, value := range body.Answers {
answers[key] = value
}
answerRaw, _ := json.Marshal(answers)
run.Answers = datatypes.JSON(answerRaw)
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
}
return advancePublicRun(tx, &run)
})
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 {
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
}
var node model.SOPNode
if err := tx.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
return err
}
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 {
return err
}
return advancePublicRun(tx, &run)
})
if err != nil {
h.respondPublicMutationError(c, err, "推进节点失败")
return
}
h.respondPublicRun(c, run, "")
}
func (h *Handler) PublicFinish(c *gin.Context) {
session, _, ok := h.publicSession(c)
if !ok {
return
}
var body struct {
Result string `json:"result"`
FinalResult json.RawMessage `json:"final_result"`
}
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" && run.Status != "completed" {
return errPublicRunCompleted
}
if body.Result == "" {
body.Result = run.Result
if body.Result == "" {
body.Result = "completed"
}
}
finalResult, err := parseFinalResult(body.FinalResult)
if err != nil {
return err
}
var scenario model.Scenario
if err := tx.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 err
}
schema, err := resultcontract.ParseAndValidate(scenario.ResultSchema)
if err != nil {
return err
}
if err := resultcontract.ValidateResult(schema, finalResult); err != nil {
return err
}
finalRaw, _ := json.Marshal(finalResult)
now := time.Now()
completedAt := run.CompletedAt
if completedAt == nil {
completedAt = &now
}
payload, _ := json.Marshal(gin.H{"result": body.Result, "final_result": finalResult})
if err := tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "finish", Payload: datatypes.JSON(payload)}).Error; err != nil {
return err
}
if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": body.Result, "final_result": datatypes.JSON(finalRaw), "completed_at": completedAt}).Error; err != nil {
return err
}
run.Status, run.Result, run.FinalResult, run.CompletedAt = "completed", body.Result, datatypes.JSON(finalRaw), completedAt
return nil
})
if err != nil {
h.respondPublicMutationError(c, err, "结束执行失败")
return
}
h.respondPublicRun(c, run, "")
}
func (h *Handler) PublicReset(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
}
var version model.SOPVersion
if err := tx.Where("id = ? AND tenant_id = ?", run.SOPVersionID, run.TenantID).First(&version).Error; err != nil {
return err
}
run.CurrentNodeKey, run.Status, run.Result, run.FinalResult, run.CompletedAt, run.Answers = version.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 {
return err
}
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
}
return tx.Create(&model.SOPRunEvent{TenantID: run.TenantID, RunID: run.ID, NodeKey: run.CurrentNodeKey, Action: "enter", Payload: datatypes.JSON([]byte(`{}`))}).Error
})
if err != nil {
h.respondPublicMutationError(c, err, "重置执行失败")
return
}
h.respondPublicRun(c, run, "")
}
func advancePublicRun(tx *gorm.DB, run *model.SOPRun) error {
var edges []model.SOPEdge
if err := tx.Where("sop_version_id = ? AND source_node_key = ? AND tenant_id = ?", run.SOPVersionID, 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_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, 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
}
func (h *Handler) respondPublicMutationError(c *gin.Context, err error, fallback string) {
switch {
case errors.Is(err, errPublicRunCompleted):
response.Error(c, http.StatusConflict, "RUN_COMPLETED", err.Error())
case errors.Is(err, errPublicNodeChanged):
response.Error(c, http.StatusConflict, "NODE_CHANGED", err.Error())
case errors.Is(err, errPublicInputNeeded):
response.Error(c, http.StatusUnprocessableEntity, "INPUT_REQUIRED", err.Error())
case errors.Is(err, gorm.ErrRecordNotFound):
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录或流程节点不存在")
default:
response.Error(c, http.StatusUnprocessableEntity, "ADVANCE_FAILED", fallback+": "+err.Error())
}
}
func (h *Handler) respondPublicRun(c *gin.Context, run model.SOPRun, token string) {
var node model.SOPNode
if err := h.db.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", run.SOPVersionID, run.CurrentNodeKey, run.TenantID).First(&node).Error; err != nil {
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)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成节点输出失败")
return
}
view.Config = nil
outputs, err := buildScenarioOutputs(h.db, run, view.Outputs)
if err != nil {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "生成场景输出失败")
return
}
if raw, marshalErr := json.Marshal(outputs); marshalErr == nil {
run.Outputs = datatypes.JSON(raw)
_ = h.db.Model(&model.SOPRun{}).Where("id = ?", run.ID).Update("outputs", run.Outputs).Error
}
var scenario model.Scenario
if err := h.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 {
response.Error(c, http.StatusInternalServerError, "OUTPUT_FAILED", "读取场景结果格式失败")
return
}
data := gin.H{"run_id": run.ID, "external_ref": run.ExternalRef, "status": run.Status, "final_result": json.RawMessage(run.FinalResult), "result_schema": json.RawMessage(scenario.ResultSchema), "node": view, "outputs": outputs}
if token != "" {
data["session_token"] = token
}
response.OK(c, data)
}
func parseFinalResult(raw json.RawMessage) (map[string]interface{}, error) {
if len(raw) == 0 || string(raw) == "null" {
return nil, nil
}
var result map[string]interface{}
if err := json.Unmarshal(raw, &result); err != nil || result == nil {
return nil, errors.New("final_result 必须是对象或 null")
}
return result, nil
}
func (h *Handler) publicSession(c *gin.Context) (model.PublicRunSession, model.SOPRun, bool) {
raw := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
var session model.PublicRunSession
if raw == "" || h.db.Where("token_hash = ? AND expires_at > ?", publicTokenHash(raw), time.Now()).First(&session).Error != nil {
response.Error(c, http.StatusUnauthorized, "INVALID_SESSION", "SDK 会话无效或已过期")
return session, model.SOPRun{}, false
}
var run model.SOPRun
if err := h.db.Where("id = ? AND tenant_id = ?", c.Param("id"), session.TenantID).First(&run).Error; err != nil || run.ID != session.RunID {
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
return session, run, false
}
return session, run, true
}
func publicTokenHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}

89
internal/run/rules.go Normal file
View File

@@ -0,0 +1,89 @@
package run
import (
"encoding/json"
"fmt"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm"
)
func deriveForScenario(db *gorm.DB, tenantID, scenarioID uint64, input map[string]interface{}) (map[string]interface{}, []string, error) {
rules := make([]model.ScenarioRule, 0)
if err := db.Where("tenant_id = ? AND scenario_id = ? AND status = ?", tenantID, scenarioID, "active").Order("priority, id").Find(&rules).Error; err != nil {
return nil, nil, err
}
return applyScenarioRules(rules, input)
}
type ruleAction struct {
Operation string `json:"operation"`
Field string `json:"field"`
Value interface{} `json:"value"`
ValueFrom string `json:"value_from"`
}
func applyScenarioRules(rules []model.ScenarioRule, input map[string]interface{}) (map[string]interface{}, []string, error) {
derived := map[string]interface{}{}
matched := make([]string, 0)
context := runtimeContext(input, derived, nil)
for _, rule := range rules {
ok, err := matchCondition(json.RawMessage(rule.Condition), context)
if err != nil {
return nil, nil, fmt.Errorf("规则 %s 条件不正确: %w", rule.RuleKey, err)
}
if !ok {
continue
}
var actions []ruleAction
if err := json.Unmarshal(rule.Actions, &actions); err != nil {
return nil, nil, fmt.Errorf("规则 %s 动作不正确", rule.RuleKey)
}
for _, action := range actions {
resolvedValues := []interface{}{action.Value}
valueFromList := false
if action.ValueFrom != "" {
resolved, exists := lookupContextValue(context, action.ValueFrom)
if !exists {
continue
}
if list, ok := resolved.([]interface{}); ok {
resolvedValues = list
valueFromList = true
} else {
resolvedValues = []interface{}{resolved}
}
}
switch action.Operation {
case "set":
if valueFromList {
derived[action.Field] = resolvedValues
} else if len(resolvedValues) == 1 {
derived[action.Field] = resolvedValues[0]
} else {
derived[action.Field] = resolvedValues
}
case "append":
targetValues, _ := derived[action.Field].([]interface{})
for _, value := range resolvedValues {
duplicate := false
for _, existing := range targetValues {
if fmt.Sprint(existing) == fmt.Sprint(value) {
duplicate = true
break
}
}
if !duplicate {
targetValues = append(targetValues, value)
}
}
derived[action.Field] = targetValues
default:
return nil, nil, fmt.Errorf("规则 %s 使用了不支持的动作", rule.RuleKey)
}
}
matched = append(matched, rule.RuleKey)
context = runtimeContext(input, derived, nil)
}
return derived, matched, nil
}

View File

@@ -0,0 +1,39 @@
package run
import (
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/datatypes"
"testing"
)
func TestApplyScenarioRules(t *testing.T) {
rules := []model.ScenarioRule{{RuleKey: "r1", Condition: datatypes.JSON([]byte(`{"field":"tags","operator":"contains","value":"soft"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"append","field":"symptoms","value":"soft_stool"}]`))}}
derived, matched, err := applyScenarioRules(rules, map[string]interface{}{"tags": []interface{}{"soft"}})
if err != nil || len(matched) != 1 || len(derived["symptoms"].([]interface{})) != 1 {
t.Fatalf("derived=%v matched=%v err=%v", derived, matched, err)
}
}
func TestApplyScenarioRulesSupportsValueFrom(t *testing.T) {
rules := []model.ScenarioRule{{RuleKey: "copy_tags", Condition: datatypes.JSON([]byte(`{"field":"input_tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"append","field":"matched_symptoms","value_from":"input_tags"}]`))}}
derived, _, err := applyScenarioRules(rules, map[string]interface{}{"input_tags": []interface{}{"soft_stool", "poor_appetite"}})
if err != nil {
t.Fatal(err)
}
values, ok := derived["matched_symptoms"].([]interface{})
if !ok || len(values) != 2 || values[0] != "soft_stool" || values[1] != "poor_appetite" {
t.Fatalf("derived = %#v", derived)
}
}
func TestApplyScenarioRulesSupportsNamespacedValueFrom(t *testing.T) {
rules := []model.ScenarioRule{{RuleKey: "copy_tags", Condition: datatypes.JSON([]byte(`{"field":"input.input_tags","operator":"exists"}`)), Actions: datatypes.JSON([]byte(`[{"operation":"set","field":"matched_symptoms","value_from":"input.input_tags"}]`))}}
derived, _, err := applyScenarioRules(rules, map[string]interface{}{"input_tags": []interface{}{"soft_stool"}})
if err != nil {
t.Fatal(err)
}
values, ok := derived["matched_symptoms"].([]interface{})
if !ok || len(values) != 1 || values[0] != "soft_stool" {
t.Fatalf("derived = %#v", derived)
}
}

29
internal/run/snapshot.go Normal file
View File

@@ -0,0 +1,29 @@
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
}

View File

@@ -0,0 +1,160 @@
package run
import (
"encoding/json"
"fmt"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
"gorm.io/gorm"
)
type NodePresentation struct {
Summary []PresentationField `json:"summary"`
Items []PresentationItem `json:"items"`
Opening *PresentationCopy `json:"opening,omitempty"`
}
type PresentationField struct {
Key string `json:"key"`
Label string `json:"label"`
Kind string `json:"kind"`
Value interface{} `json:"value"`
}
type PresentationItem struct {
Fields []PresentationField `json:"fields"`
}
type PresentationCopy struct {
Title string `json:"title"`
Content string `json:"content"`
}
type startNodeConfig struct {
Presentation *startPresentationConfig `json:"presentation"`
}
type startPresentationConfig struct {
SummaryFieldKeys []string `json:"summary_field_keys"`
ItemFieldKeys []string `json:"item_field_keys"`
ImageFieldKeys []string `json:"image_field_keys"`
OpeningTitle string `json:"opening_title"`
OpeningTemplate string `json:"opening_template"`
}
func loadStartPresentation(db *gorm.DB, node model.SOPNode, tenantID uint64, context map[string]interface{}) (*NodePresentation, error) {
var config startNodeConfig
if err := json.Unmarshal(node.Config, &config); err != nil {
return nil, err
}
if config.Presentation == nil {
return nil, nil
}
keys := append([]string{}, config.Presentation.SummaryFieldKeys...)
keys = append(keys, config.Presentation.ItemFieldKeys...)
fields := make([]model.ScenarioField, 0)
if len(keys) > 0 {
if err := db.Table("scenario_fields sf").Select("sf.*").
Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").
Joins("JOIN sop_versions sv ON sv.sop_id = s.id").
Where("sv.id = ? AND sf.tenant_id = ? AND sf.field_key IN ?", node.SOPVersionID, 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
}
return buildStartPresentation(*config.Presentation, byKey, context), nil
}
func buildStartPresentation(config startPresentationConfig, fields map[string]model.ScenarioField, context map[string]interface{}) *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)
if !ok || presentationValueEmpty(value) {
continue
}
view.Summary = append(view.Summary, presentationField(key, value, fields, imageKeys))
}
itemValues := make(map[string][]interface{}, len(config.ItemFieldKeys))
itemCount := 0
for _, key := range config.ItemFieldKeys {
value, _ := lookupContextValue(context, "input."+key)
values := presentationValues(value)
itemValues[key] = values
if len(values) > itemCount {
itemCount = len(values)
}
}
for index := 0; index < itemCount; index++ {
item := PresentationItem{Fields: []PresentationField{}}
for _, key := range config.ItemFieldKeys {
values := itemValues[key]
if index >= len(values) || presentationValueEmpty(values[index]) {
continue
}
item.Fields = append(item.Fields, presentationField(key, values[index], fields, imageKeys))
}
if len(item.Fields) > 0 {
view.Items = append(view.Items, item)
}
}
if config.OpeningTemplate != "" {
title := config.OpeningTitle
if title == "" {
title = "开场话术"
}
view.Opening = &PresentationCopy{Title: title, Content: fmt.Sprint(renderKnowledgeValue(config.OpeningTemplate, context))}
}
return view
}
func presentationField(key string, value interface{}, fields map[string]model.ScenarioField, imageKeys map[string]bool) PresentationField {
label := key
if field, ok := fields[key]; ok && field.FieldName != "" {
label = field.FieldName
}
kind := "text"
if imageKeys[key] {
kind = "image"
}
return PresentationField{Key: key, Label: label, Kind: kind, Value: value}
}
func presentationValues(value interface{}) []interface{} {
switch values := value.(type) {
case []interface{}:
return values
case []string:
result := make([]interface{}, 0, len(values))
for _, item := range values {
result = append(result, item)
}
return result
case nil:
return nil
default:
return []interface{}{value}
}
}
func presentationValueEmpty(value interface{}) bool {
switch typed := value.(type) {
case nil:
return true
case string:
return typed == ""
case []interface{}:
return len(typed) == 0
case []string:
return len(typed) == 0
default:
return false
}
}

View File

@@ -0,0 +1,46 @@
package run
import (
"testing"
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
)
func TestBuildStartPresentation(t *testing.T) {
config := startPresentationConfig{
SummaryFieldKeys: []string{"order_id", "customer_name", "pet_name"},
ItemFieldKeys: []string{"product_images", "product_names", "product_ids"},
ImageFieldKeys: []string{"product_images"},
OpeningTitle: "开场话术",
OpeningTemplate: "您好,{{input.customer_name}},看到您购买了{{input.product_names}}。",
}
fields := map[string]model.ScenarioField{
"order_id": {FieldKey: "order_id", FieldName: "订单号"},
"customer_name": {FieldKey: "customer_name", FieldName: "客户称呼"},
"pet_name": {FieldKey: "pet_name", FieldName: "宠物名称"},
"product_images": {FieldKey: "product_images", FieldName: "订单商品图片"},
"product_names": {FieldKey: "product_names", FieldName: "订单商品名称"},
"product_ids": {FieldKey: "product_ids", FieldName: "订单商品 ID"},
}
context := runtimeContext(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)
got := buildStartPresentation(config, fields, context)
if len(got.Summary) != 2 {
t.Fatalf("summary length = %d, want 2", len(got.Summary))
}
if len(got.Items) != 2 || len(got.Items[0].Fields) != 3 {
t.Fatalf("items = %#v, want two complete product rows", got.Items)
}
if got.Items[0].Fields[0].Kind != "image" {
t.Fatalf("image kind = %q, want image", got.Items[0].Fields[0].Kind)
}
if got.Opening == nil || got.Opening.Content != "您好,王女士,看到您购买了商品 A、商品 B。" {
t.Fatalf("opening = %#v", got.Opening)
}
}

View File

@@ -10,10 +10,37 @@ import (
)
type answerNodeConfig struct {
FieldKey string `json:"field_key"`
FieldKeys []string `json:"field_keys"`
Required bool `json:"required"`
Options []string `json:"options"`
FieldKey string `json:"field_key"`
FieldKeys []string `json:"field_keys"`
RequiredFieldKeys []string `json:"required_field_keys"`
Required bool `json:"required"`
Options []string `json:"options"`
}
// validateInitialAnswers accepts a partial, externally supplied set of values
// when an execution starts. Required fields are still enforced by their
// collection nodes, so integrations can supply only the data they possess.
func validateInitialAnswers(fields []model.ScenarioField, answers map[string]interface{}) error {
fieldMap := make(map[string]model.ScenarioField, len(fields))
for _, field := range fields {
fieldMap[field.FieldKey] = field
if field.Required && field.SourcePath != "" && isEmptyValue(answers[field.FieldKey]) {
return fmt.Errorf("缺少必填输入%s", field.FieldName)
}
}
for key, value := range answers {
field, exists := fieldMap[key]
if !exists {
return fmt.Errorf("传入字段 %s 不存在", key)
}
if isEmptyValue(value) {
continue
}
if err := validateFieldValue(field, value); err != nil {
return err
}
}
return nil
}
func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answers map[string]interface{}) error {
@@ -39,9 +66,12 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe
return fmt.Errorf("当前表单没有配置采集字段")
}
for _, key := range config.FieldKeys {
expected[key] = false
expected[key] = containsString(config.RequiredFieldKeys, key)
}
default:
if node.Type == "knowledge" {
return validateKnowledgeNodeAnswers(node, fieldMap, answers)
}
if len(answers) > 0 {
return fmt.Errorf("当前节点不接受字段回答")
}
@@ -76,6 +106,87 @@ 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:
return []string{typed}, typed != ""
case []interface{}:
result := make([]string, 0, len(typed))
for _, item := range typed {
text, ok := item.(string)
if !ok || text == "" {
return nil, false
}
result = append(result, text)
}
return result, true
case []string:
return typed, true
}
return nil, false
}
func stringAllowed(options []string, selected string) bool {
for _, option := range options {
if option == selected {
@@ -140,6 +251,15 @@ func validateFieldValue(field model.ScenarioField, value interface{}) error {
return fmt.Errorf("%s包含不正确的选项", field.FieldName)
}
}
case "array":
switch values := value.(type) {
case []interface{}:
_ = values
case []string:
_ = values
default:
return fmt.Errorf("%s必须是数组", field.FieldName)
}
case "date":
text, ok := value.(string)
if !ok || !validDate(text) {

View File

@@ -47,6 +47,14 @@ func TestValidateNodeAnswersRejectsAnswersForMessage(t *testing.T) {
}
}
func TestValidateNodeAnswersSupportsNodeRequiredFormFields(t *testing.T) {
fields := []model.ScenarioField{{FieldKey: "pet_name", FieldName: "宠物名称", FieldType: "text"}}
node := model.SOPNode{Type: "form", Config: datatypes.JSON([]byte(`{"field_keys":["pet_name"],"required_field_keys":["pet_name"]}`))}
if err := validateNodeAnswers(node, fields, map[string]interface{}{}); err == nil || !strings.Contains(err.Error(), "请填写宠物名称") {
t.Fatalf("validateNodeAnswers() error = %v, want node-required error", err)
}
}
func TestValidateNodeAnswersChoiceOptions(t *testing.T) {
fields := []model.ScenarioField{{FieldKey: "intent", FieldName: "客户意向", FieldType: "text"}}
node := model.SOPNode{Type: "choice", Config: datatypes.JSON([]byte(`{"field_key":"intent","required":true,"options":["继续了解","暂不考虑"]}`))}
@@ -58,3 +66,26 @@ func TestValidateNodeAnswersChoiceOptions(t *testing.T) {
t.Fatalf("validateNodeAnswers() error = %v, want invalid choice option", err)
}
}
func TestValidateInitialAnswers(t *testing.T) {
fields := []model.ScenarioField{
{FieldKey: "pet_name", FieldName: "宠物名称", FieldType: "text", Required: true},
{FieldKey: "pet_weight", FieldName: "体重", FieldType: "number"},
}
if err := validateInitialAnswers(fields, map[string]interface{}{"pet_name": "团子"}); err != nil {
t.Fatalf("validateInitialAnswers() error = %v", err)
}
if err := validateInitialAnswers(fields, map[string]interface{}{"unknown": "value"}); err == nil || !strings.Contains(err.Error(), "不存在") {
t.Fatalf("validateInitialAnswers() error = %v, want unknown-field error", err)
}
if err := validateInitialAnswers(fields, map[string]interface{}{"pet_weight": "heavy"}); err == nil || !strings.Contains(err.Error(), "必须是数字") {
t.Fatalf("validateInitialAnswers() error = %v, want value-type error", err)
}
}
func TestValidateInitialAnswersRequiresMappedInput(t *testing.T) {
fields := []model.ScenarioField{{FieldKey: "customer_id", FieldName: "客户 ID", FieldType: "text", SourcePath: "customer.id", Required: true}}
if err := validateInitialAnswers(fields, map[string]interface{}{}); err == nil {
t.Fatal("expected missing mapped input to fail")
}
}