feat: simplify SOP to immediate-effect configuration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
50
internal/run/generic_scenario_test.go
Normal file
50
internal/run/generic_scenario_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
79
internal/run/knowledge_collection.go
Normal file
79
internal/run/knowledge_collection.go
Normal 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
|
||||
}
|
||||
62
internal/run/knowledge_collection_test.go
Normal file
62
internal/run/knowledge_collection_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
88
internal/run/knowledge_graph_test.go
Normal file
88
internal/run/knowledge_graph_test.go
Normal 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
93
internal/run/mapping.go
Normal 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
|
||||
}
|
||||
24
internal/run/mapping_test.go
Normal file
24
internal/run/mapping_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
22
internal/run/node_view_test.go
Normal file
22
internal/run/node_view_test.go
Normal 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
101
internal/run/outputs.go
Normal 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
|
||||
}
|
||||
21
internal/run/outputs_test.go
Normal file
21
internal/run/outputs_test.go
Normal 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
62
internal/run/preview.go
Normal 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
458
internal/run/public.go
Normal 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
89
internal/run/rules.go
Normal 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
|
||||
}
|
||||
39
internal/run/rules_test.go
Normal file
39
internal/run/rules_test.go
Normal 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
29
internal/run/snapshot.go
Normal 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
|
||||
}
|
||||
160
internal/run/start_presentation.go
Normal file
160
internal/run/start_presentation.go
Normal 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
|
||||
}
|
||||
}
|
||||
46
internal/run/start_presentation_test.go
Normal file
46
internal/run/start_presentation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user