Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a952232cc9 | |||
| 4dc3f22784 | |||
| b8f572cc84 | |||
| fcf4559c9a | |||
| 9fa2eac6a4 | |||
| da69226481 |
@@ -22,6 +22,10 @@ DOBOTAI_TENANT_KEY=
|
||||
# When set, a successful QA answer will be delivered as an AI card; markdown
|
||||
# is only sent as a fallback when card delivery fails. Never commit real values.
|
||||
DINGTALK_CARD_TEMPLATE_ID=
|
||||
# Optional, temporary DingTalk card-debug allowlist. When set, only this exact
|
||||
# DingTalk user receives _CARD_DEBUG_TOOL_ENTRY=show in per-user privateData.
|
||||
# Keep empty outside a controlled debug session; never commit a real user ID.
|
||||
DINGTALK_CARD_DEBUG_USER_ID=
|
||||
|
||||
# Independent DobotAI PostgreSQL; never point this at WeKnora's database
|
||||
DATABASE_URL=postgres://dobotai:replace-me@127.0.0.1:5432/dobotai?sslmode=disable
|
||||
|
||||
+11
-1
@@ -92,10 +92,20 @@ func main() {
|
||||
}
|
||||
var cardSender dingtalk.FinalCardSender
|
||||
if cardTemplateID := strings.TrimSpace(os.Getenv("DINGTALK_CARD_TEMPLATE_ID")); cardTemplateID != "" {
|
||||
cs, err := dingtalk.NewCardSender(dingCfg.ClientID, dingCfg.ClientSecret, cardTemplateID, nil)
|
||||
cardDebugUserID := os.Getenv("DINGTALK_CARD_DEBUG_USER_ID")
|
||||
cs, err := dingtalk.NewCardSender(
|
||||
dingCfg.ClientID,
|
||||
dingCfg.ClientSecret,
|
||||
cardTemplateID,
|
||||
nil,
|
||||
dingtalk.WithCardDebugUserID(cardDebugUserID),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("DingTalk card sender init failed: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(cardDebugUserID) != "" {
|
||||
log.Printf("DingTalk card debug enabled for one allowlisted user")
|
||||
}
|
||||
cardSender = cs
|
||||
}
|
||||
handler, err := dingtalk.NewHandlerWithOptions(
|
||||
|
||||
@@ -85,3 +85,19 @@ func TestMainWiresFeedbackIntoDingTalkStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainWiresOptionalDingTalkCardDebugUser(t *testing.T) {
|
||||
data, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{
|
||||
`os.Getenv("DINGTALK_CARD_DEBUG_USER_ID")`,
|
||||
`dingtalk.WithCardDebugUserID(cardDebugUserID)`,
|
||||
} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("main card debug wiring missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 000006_native_card_feedback_reasons down migration
|
||||
-- A schema rollback cannot retain values rejected by the legacy constraint,
|
||||
-- so map each native reason to its closest B2 legacy category first.
|
||||
|
||||
UPDATE answer_feedback
|
||||
SET reason_code = CASE reason_code
|
||||
WHEN 'content_mismatch' THEN 'irrelevant'
|
||||
WHEN 'content_incomplete' THEN 'not_resolved'
|
||||
WHEN 'illogical_expression' THEN 'unclear'
|
||||
WHEN 'factual_error' THEN 'incorrect'
|
||||
WHEN 'sensitive_content' THEN 'other'
|
||||
WHEN 'answer_not_wanted' THEN 'not_resolved'
|
||||
ELSE reason_code
|
||||
END
|
||||
WHERE reason_code IN (
|
||||
'content_mismatch',
|
||||
'content_incomplete',
|
||||
'illogical_expression',
|
||||
'factual_error',
|
||||
'sensitive_content',
|
||||
'answer_not_wanted'
|
||||
);
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
ADD CONSTRAINT answer_feedback_reason_code_check
|
||||
CHECK (reason_code IN ('', 'incorrect', 'not_resolved', 'irrelevant', 'unclear', 'outdated', 'other'));
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 000006_native_card_feedback_reasons up migration
|
||||
-- Preserve the original B2 reasons and add the six exact reason codes emitted
|
||||
-- by DingTalk's built-in AI feedback component.
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
DROP CONSTRAINT IF EXISTS answer_feedback_reason_code_check;
|
||||
|
||||
ALTER TABLE answer_feedback
|
||||
ADD CONSTRAINT answer_feedback_reason_code_check
|
||||
CHECK (reason_code IN (
|
||||
'',
|
||||
'incorrect',
|
||||
'not_resolved',
|
||||
'irrelevant',
|
||||
'unclear',
|
||||
'outdated',
|
||||
'other',
|
||||
'content_mismatch',
|
||||
'content_incomplete',
|
||||
'illogical_expression',
|
||||
'factual_error',
|
||||
'sensitive_content',
|
||||
'answer_not_wanted'
|
||||
));
|
||||
@@ -0,0 +1,78 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read006Migration(t *testing.T, direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(".", "000006_native_card_feedback_reasons."+direction+".sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read 000006 %s migration: %v", direction, err)
|
||||
}
|
||||
return strings.Join(strings.Fields(strings.ToUpper(string(data))), " ")
|
||||
}
|
||||
|
||||
func TestUp006ExpandsNamedReasonConstraint(t *testing.T) {
|
||||
up := read006Migration(t, "up")
|
||||
for _, fragment := range []string{
|
||||
"ALTER TABLE ANSWER_FEEDBACK DROP CONSTRAINT IF EXISTS ANSWER_FEEDBACK_REASON_CODE_CHECK",
|
||||
"ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK",
|
||||
"CHECK (REASON_CODE IN",
|
||||
"CONTENT_MISMATCH",
|
||||
"CONTENT_INCOMPLETE",
|
||||
"ILLOGICAL_EXPRESSION",
|
||||
"FACTUAL_ERROR",
|
||||
"SENSITIVE_CONTENT",
|
||||
"ANSWER_NOT_WANTED",
|
||||
} {
|
||||
if !strings.Contains(up, fragment) {
|
||||
t.Errorf("up migration missing %q", fragment)
|
||||
}
|
||||
}
|
||||
for _, legacy := range []string{"INCORRECT", "NOT_RESOLVED", "IRRELEVANT", "UNCLEAR", "OUTDATED", "OTHER"} {
|
||||
if !strings.Contains(up, legacy) {
|
||||
t.Errorf("up migration must preserve legacy reason %q", legacy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp006OnlyChangesReasonConstraint(t *testing.T) {
|
||||
up := read006Migration(t, "up")
|
||||
for _, forbidden := range []string{"DROP TABLE", "DROP COLUMN", "TRUNCATE", "DELETE FROM", "UPDATE ANSWER_FEEDBACK"} {
|
||||
if strings.Contains(up, forbidden) {
|
||||
t.Errorf("up migration contains forbidden operation %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDown006MapsNativeReasonsBeforeRestoringConstraint(t *testing.T) {
|
||||
down := read006Migration(t, "down")
|
||||
mappings := map[string]string{
|
||||
"CONTENT_MISMATCH": "IRRELEVANT",
|
||||
"CONTENT_INCOMPLETE": "NOT_RESOLVED",
|
||||
"ILLOGICAL_EXPRESSION": "UNCLEAR",
|
||||
"FACTUAL_ERROR": "INCORRECT",
|
||||
"SENSITIVE_CONTENT": "OTHER",
|
||||
"ANSWER_NOT_WANTED": "NOT_RESOLVED",
|
||||
}
|
||||
updateAt := strings.Index(down, "UPDATE ANSWER_FEEDBACK")
|
||||
constraintAt := strings.Index(down, "ADD CONSTRAINT ANSWER_FEEDBACK_REASON_CODE_CHECK")
|
||||
if updateAt < 0 || constraintAt < 0 || updateAt > constraintAt {
|
||||
t.Fatal("down migration must map native reasons before restoring the legacy constraint")
|
||||
}
|
||||
for from, to := range mappings {
|
||||
if !strings.Contains(down, "WHEN '"+from+"' THEN '"+to+"'") {
|
||||
t.Errorf("down migration missing mapping %s -> %s", from, to)
|
||||
}
|
||||
}
|
||||
for _, native := range []string{"CONTENT_MISMATCH", "CONTENT_INCOMPLETE", "ILLOGICAL_EXPRESSION", "FACTUAL_ERROR", "SENSITIVE_CONTENT", "ANSWER_NOT_WANTED"} {
|
||||
constraint := down[constraintAt:]
|
||||
if strings.Contains(constraint, native) {
|
||||
t.Errorf("legacy down constraint must exclude native reason %q", native)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
|
||||
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
|
||||
cardCreateURL = "https://api.dingtalk.com/v1.0/card/instances/createAndDeliver"
|
||||
cardStreamingURL = "https://api.dingtalk.com/v1.0/card/streaming"
|
||||
cardDebugPrivateKey = "_CARD_DEBUG_TOOL_ENTRY"
|
||||
)
|
||||
|
||||
// CardRequest is the input for sending a final AI card.
|
||||
@@ -39,26 +40,38 @@ type CardSender struct {
|
||||
streamURL string
|
||||
apiClient *http.Client
|
||||
allowTestURL bool
|
||||
debugUserID string
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
tokenTill time.Time
|
||||
}
|
||||
|
||||
// CardSenderOption configures optional CardSender behavior.
|
||||
type CardSenderOption func(*CardSender)
|
||||
|
||||
// WithCardDebugUserID enables DingTalk's official card debug entry only for
|
||||
// the matching user through per-user privateData. A blank value keeps it off.
|
||||
func WithCardDebugUserID(userID string) CardSenderOption {
|
||||
return func(sender *CardSender) {
|
||||
sender.debugUserID = strings.TrimSpace(userID)
|
||||
}
|
||||
}
|
||||
|
||||
// NewCardSender creates a CardSender. All three config values are required
|
||||
// after trimming whitespace. A nil http.Client installs a hardened default
|
||||
// (no redirects, Proxy=nil, 15s timeout).
|
||||
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client) (*CardSender, error) {
|
||||
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false)
|
||||
func NewCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, options ...CardSenderOption) (*CardSender, error) {
|
||||
return newCardSender(clientID, clientSecret, cardTemplateID, client, oauthTokenURL, cardCreateURL, cardStreamingURL, false, options...)
|
||||
}
|
||||
|
||||
// newCardSenderForTest is used by tests to inject httptest URLs.
|
||||
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string) (*CardSender, error) {
|
||||
func newCardSenderForTest(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL string, options ...CardSenderOption) (*CardSender, error) {
|
||||
streamURL := strings.TrimSuffix(createURL, "/create") + "/stream"
|
||||
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true)
|
||||
return newCardSender(clientID, clientSecret, cardTemplateID, client, tokenURL, createURL, streamURL, true, options...)
|
||||
}
|
||||
|
||||
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool) (*CardSender, error) {
|
||||
func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.Client, tokenURL, createURL, streamURL string, allowTest bool, options ...CardSenderOption) (*CardSender, error) {
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
clientSecret = strings.TrimSpace(clientSecret)
|
||||
cardTemplateID = strings.TrimSpace(cardTemplateID)
|
||||
@@ -91,7 +104,7 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
|
||||
apiClient.Transport = transport
|
||||
}
|
||||
|
||||
return &CardSender{
|
||||
sender := &CardSender{
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
cardTemplateID: cardTemplateID,
|
||||
@@ -100,7 +113,13 @@ func newCardSender(clientID, clientSecret, cardTemplateID string, client *http.C
|
||||
streamURL: streamURL,
|
||||
apiClient: &apiClient,
|
||||
allowTestURL: allowTest,
|
||||
}, nil
|
||||
}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
option(sender)
|
||||
}
|
||||
}
|
||||
return sender, nil
|
||||
}
|
||||
|
||||
// SendFinalCard sends the AI card to DingTalk and returns the generated outTrackID.
|
||||
@@ -134,9 +153,10 @@ func (s *CardSender) StartCard(ctx context.Context, req CardRequest) (string, er
|
||||
return "", errors.New("dingtalk card: content exceeds 64 KiB")
|
||||
}
|
||||
|
||||
// Validate identity fields
|
||||
// Validate identity fields. ExternalUserID is also available for group
|
||||
// askers and is used only for per-user card private data.
|
||||
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
|
||||
if req.ConversationType == core.ConversationDirect {
|
||||
req.ExternalUserID = strings.TrimSpace(req.ExternalUserID)
|
||||
if req.ExternalUserID == "" {
|
||||
return "", errors.New("dingtalk card: ExternalUserID is required for direct conversations")
|
||||
}
|
||||
@@ -232,6 +252,15 @@ func (s *CardSender) buildCardBody(req CardRequest, outTrackID string) map[strin
|
||||
"extension": map[string]any{},
|
||||
}
|
||||
}
|
||||
if s.debugUserID != "" && req.ExternalUserID == s.debugUserID {
|
||||
body["privateData"] = map[string]any{
|
||||
req.ExternalUserID: map[string]any{
|
||||
"cardParamMap": map[string]any{
|
||||
cardDebugPrivateKey: "show",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
|
||||
@@ -271,6 +271,117 @@ func TestCardSendDirectPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: official DingTalk card debug private data for one allowlisted user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCardSendDirectAddsDebugPrivateDataForSelectedUser(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
sender, err := newCardSenderForTest(
|
||||
"client-id",
|
||||
"client-secret",
|
||||
"card-template-123",
|
||||
ts.Client(),
|
||||
ts.URL()+"/token",
|
||||
ts.URL()+"/create",
|
||||
WithCardDebugUserID(" user-debug "),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = sender.SendFinalCard(context.Background(), CardRequest{
|
||||
ConversationType: core.ConversationDirect,
|
||||
ExternalUserID: "user-debug",
|
||||
Content: "debug this card",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendFinalCard: %v", err)
|
||||
}
|
||||
|
||||
privateData, ok := ts.lastCreateBody["privateData"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("privateData missing or wrong type")
|
||||
}
|
||||
userData, ok := privateData["user-debug"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("selected user's privateData missing or wrong type")
|
||||
}
|
||||
paramMap, ok := userData["cardParamMap"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("selected user's cardParamMap missing or wrong type")
|
||||
}
|
||||
if got := paramMap["_CARD_DEBUG_TOOL_ENTRY"]; got != "show" {
|
||||
t.Fatalf("_CARD_DEBUG_TOOL_ENTRY=%v, want show", got)
|
||||
}
|
||||
if len(privateData) != 1 {
|
||||
t.Fatalf("privateData has %d users, want 1", len(privateData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardSendOmitsDebugPrivateDataForOtherUser(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
sender, err := newCardSenderForTest(
|
||||
"client-id", "client-secret", "card-template-123",
|
||||
ts.Client(), ts.URL()+"/token", ts.URL()+"/create",
|
||||
WithCardDebugUserID("user-debug"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = sender.SendFinalCard(context.Background(), CardRequest{
|
||||
ConversationType: core.ConversationDirect,
|
||||
ExternalUserID: "user-other",
|
||||
Content: "ordinary card",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendFinalCard: %v", err)
|
||||
}
|
||||
if _, ok := ts.lastCreateBody["privateData"]; ok {
|
||||
t.Fatal("privateData must be absent for a non-allowlisted user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardSendGroupAddsDebugPrivateDataOnlyForAsker(t *testing.T) {
|
||||
ts := newCardTestServer()
|
||||
defer ts.Close()
|
||||
|
||||
sender, err := newCardSenderForTest(
|
||||
"client-id", "client-secret", "card-template-123",
|
||||
ts.Client(), ts.URL()+"/token", ts.URL()+"/create",
|
||||
WithCardDebugUserID("user-debug"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = sender.SendFinalCard(context.Background(), CardRequest{
|
||||
ConversationType: core.ConversationGroup,
|
||||
ExternalUserID: " user-debug ",
|
||||
ExternalChatID: "group-789",
|
||||
Content: "group debug card",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendFinalCard: %v", err)
|
||||
}
|
||||
|
||||
privateData, ok := ts.lastCreateBody["privateData"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("privateData missing or wrong type")
|
||||
}
|
||||
if _, ok := privateData["user-debug"]; !ok {
|
||||
t.Fatal("group asker's privateData missing")
|
||||
}
|
||||
if len(privateData) != 1 {
|
||||
t.Fatalf("privateData has %d users, want 1", len(privateData))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: group conversation full payload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,7 @@ package dingtalk
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
sdkcard "github.com/open-dingtalk/dingtalk-stream-sdk-go/card"
|
||||
|
||||
@@ -10,16 +11,32 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ActionFeedbackUp = "dobotai_feedback_up"
|
||||
ActionFeedbackDown = "dobotai_feedback_down"
|
||||
ActionReasonIncorrect = "dobotai_reason_incorrect"
|
||||
ActionReasonNotResolved = "dobotai_reason_not_resolved"
|
||||
ActionReasonIrrelevant = "dobotai_reason_irrelevant"
|
||||
ActionReasonUnclear = "dobotai_reason_unclear"
|
||||
ActionReasonOutdated = "dobotai_reason_outdated"
|
||||
ActionReasonOther = "dobotai_reason_other"
|
||||
ActionNativeFeedback = "sys_action_like_component"
|
||||
ActionFeedbackUp = "dobotai_feedback_up"
|
||||
ActionFeedbackDown = "dobotai_feedback_down"
|
||||
ActionReasonIncorrect = "dobotai_reason_incorrect"
|
||||
ActionReasonNotResolved = "dobotai_reason_not_resolved"
|
||||
ActionReasonIrrelevant = "dobotai_reason_irrelevant"
|
||||
ActionReasonUnclear = "dobotai_reason_unclear"
|
||||
ActionReasonOutdated = "dobotai_reason_outdated"
|
||||
ActionReasonOther = "dobotai_reason_other"
|
||||
ActionReasonContentMismatch = "dobotai_reason_content_mismatch"
|
||||
ActionReasonContentIncomplete = "dobotai_reason_content_incomplete"
|
||||
ActionReasonIllogical = "dobotai_reason_illogical_expression"
|
||||
ActionReasonFactualError = "dobotai_reason_factual_error"
|
||||
ActionReasonSensitiveContent = "dobotai_reason_sensitive_content"
|
||||
ActionReasonAnswerNotWanted = "dobotai_reason_answer_not_wanted"
|
||||
)
|
||||
|
||||
var nativeReasonByTag = map[string]string{
|
||||
"1": feedbackdomain.ReasonContentMismatch,
|
||||
"2": feedbackdomain.ReasonContentIncomplete,
|
||||
"3": feedbackdomain.ReasonIllogical,
|
||||
"4": feedbackdomain.ReasonFactualError,
|
||||
"5": feedbackdomain.ReasonSensitiveContent,
|
||||
"6": feedbackdomain.ReasonAnswerNotWanted,
|
||||
}
|
||||
|
||||
type feedbackService interface {
|
||||
Apply(context.Context, feedbackdomain.Command) (feedbackdomain.Result, error)
|
||||
}
|
||||
@@ -39,7 +56,10 @@ func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.Ca
|
||||
if request == nil {
|
||||
return nil, errors.New("dingtalk card feedback: request is nil")
|
||||
}
|
||||
value, reason, recognized := feedbackAction(request.CardActionData.CardPrivateData.ActionIdList)
|
||||
value, reason, recognized := feedbackAction(
|
||||
request.CardActionData.CardPrivateData.ActionIdList,
|
||||
request.CardActionData.CardPrivateData.Params,
|
||||
)
|
||||
if !recognized {
|
||||
return &sdkcard.CardResponse{}, nil
|
||||
}
|
||||
@@ -74,10 +94,13 @@ func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.Ca
|
||||
}, nil
|
||||
}
|
||||
|
||||
func feedbackAction(actions []string) (string, string, bool) {
|
||||
func feedbackAction(actions []string, params map[string]any) (string, string, bool) {
|
||||
value, reason, matches := "", "", 0
|
||||
for _, action := range actions {
|
||||
candidateValue, candidateReason, ok := oneFeedbackAction(action)
|
||||
if action == ActionNativeFeedback {
|
||||
candidateValue, candidateReason, ok = nativeFeedbackAction(params)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -87,6 +110,50 @@ func feedbackAction(actions []string) (string, string, bool) {
|
||||
return value, reason, matches == 1
|
||||
}
|
||||
|
||||
func nativeFeedbackAction(params map[string]any) (string, string, bool) {
|
||||
key, keyOK := params["variableKey"].(string)
|
||||
if !keyOK || !strings.HasPrefix(key, "node_") {
|
||||
return "", "", false
|
||||
}
|
||||
if nativeVariableKey(key, "_likeOrDislike") {
|
||||
value, ok := params["variableValue"].(string)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
switch value {
|
||||
case "like":
|
||||
return feedbackdomain.ValueUp, feedbackdomain.ReasonEmpty, true
|
||||
case "dislike":
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonEmpty, true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
if nativeVariableKey(key, "_dislikeTags") {
|
||||
values, ok := params["variableValue"].([]any)
|
||||
if !ok || len(values) != 1 {
|
||||
return "", "", false
|
||||
}
|
||||
tag, ok := values[0].(string)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
reason, ok := nativeReasonByTag[tag]
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return feedbackdomain.ValueDown, reason, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func nativeVariableKey(key, suffix string) bool {
|
||||
if !strings.HasPrefix(key, "node_") || !strings.HasSuffix(key, suffix) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSuffix(strings.TrimPrefix(key, "node_"), suffix) != ""
|
||||
}
|
||||
|
||||
func oneFeedbackAction(action string) (string, string, bool) {
|
||||
switch action {
|
||||
case ActionFeedbackUp:
|
||||
@@ -105,6 +172,18 @@ func oneFeedbackAction(action string) (string, string, bool) {
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonOutdated, true
|
||||
case ActionReasonOther:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonOther, true
|
||||
case ActionReasonContentMismatch:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonContentMismatch, true
|
||||
case ActionReasonContentIncomplete:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonContentIncomplete, true
|
||||
case ActionReasonIllogical:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonIllogical, true
|
||||
case ActionReasonFactualError:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonFactualError, true
|
||||
case ActionReasonSensitiveContent:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonSensitiveContent, true
|
||||
case ActionReasonAnswerNotWanted:
|
||||
return feedbackdomain.ValueDown, feedbackdomain.ReasonAnswerNotWanted, true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
@@ -43,6 +43,155 @@ func cardRequest(action string) *sdkcard.CardRequest {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsNativeLike(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueUp}}
|
||||
handler, err := NewCardFeedbackHandler(service)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := cardRequest(ActionNativeFeedback)
|
||||
request.CardActionData.CardPrivateData.Params = map[string]any{
|
||||
"variableKey": "node_ocmrjfmlsra_likeOrDislike",
|
||||
"variableValue": "like",
|
||||
}
|
||||
response, err := handler.HandleSDK(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.calls != 1 || service.command.Value != feedbackdomain.ValueUp || service.command.ReasonCode != feedbackdomain.ReasonEmpty {
|
||||
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackSelected"] != feedbackdomain.ValueUp {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsNativeDislike(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueDown}}
|
||||
handler, err := NewCardFeedbackHandler(service)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := cardRequest(ActionNativeFeedback)
|
||||
request.CardActionData.CardPrivateData.Params = map[string]any{
|
||||
"variableKey": "node_ocmrjfmlsra_likeOrDislike",
|
||||
"variableValue": "dislike",
|
||||
}
|
||||
response, err := handler.HandleSDK(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonEmpty {
|
||||
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["showFeedbackReasons"] != "true" {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsNativeReasons(t *testing.T) {
|
||||
tests := []struct {
|
||||
tag string
|
||||
reason string
|
||||
}{
|
||||
{"1", feedbackdomain.ReasonContentMismatch},
|
||||
{"2", feedbackdomain.ReasonContentIncomplete},
|
||||
{"3", feedbackdomain.ReasonIllogical},
|
||||
{"4", feedbackdomain.ReasonFactualError},
|
||||
{"5", feedbackdomain.ReasonSensitiveContent},
|
||||
{"6", feedbackdomain.ReasonAnswerNotWanted},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.tag, func(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: tc.reason}}
|
||||
handler, err := NewCardFeedbackHandler(service)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := cardRequest(ActionNativeFeedback)
|
||||
request.CardActionData.CardPrivateData.Params = map[string]any{
|
||||
"variableKey": "node_ocmrjfmlsra_dislikeTags",
|
||||
"variableValue": []any{tc.tag},
|
||||
}
|
||||
response, err := handler.HandleSDK(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != tc.reason {
|
||||
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackReasonSelected"] != tc.reason {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackRejectsMalformedNativeEvents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
actions []string
|
||||
params map[string]any
|
||||
}{
|
||||
{name: "empty node ID", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node__likeOrDislike", "variableValue": "like"}},
|
||||
{name: "unknown suffix", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_other", "variableValue": "like"}},
|
||||
{name: "unknown vote", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": "LIKE"}},
|
||||
{name: "wrong vote type", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": []any{"like"}}},
|
||||
{name: "empty tags", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{}}},
|
||||
{name: "multiple tags", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{"1", "2"}}},
|
||||
{name: "numeric tag", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{1}}},
|
||||
{name: "unknown tag", actions: []string{ActionNativeFeedback}, params: map[string]any{"variableKey": "node_x_dislikeTags", "variableValue": []any{"7"}}},
|
||||
{name: "ambiguous actions", actions: []string{ActionNativeFeedback, ActionFeedbackUp}, params: map[string]any{"variableKey": "node_x_likeOrDislike", "variableValue": "like"}},
|
||||
{name: "nil params", actions: []string{ActionNativeFeedback}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
service := &fakeFeedbackService{}
|
||||
handler, _ := NewCardFeedbackHandler(service)
|
||||
request := cardRequest("unknown")
|
||||
request.CardActionData.CardPrivateData.ActionIdList = tc.actions
|
||||
request.CardActionData.CardPrivateData.Params = tc.params
|
||||
response, err := handler.HandleSDK(context.Background(), request)
|
||||
if err != nil || response == nil || service.calls != 0 {
|
||||
t.Fatalf("response=%+v error=%v calls=%d", response, err, service.calls)
|
||||
}
|
||||
if response.CardUpdateOptions != nil || response.UserPrivateData != nil {
|
||||
t.Fatalf("safe ACK must not update card: %+v", response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsCustomExactReasons(t *testing.T) {
|
||||
tests := []struct {
|
||||
action string
|
||||
reason string
|
||||
}{
|
||||
{ActionReasonContentMismatch, feedbackdomain.ReasonContentMismatch},
|
||||
{ActionReasonContentIncomplete, feedbackdomain.ReasonContentIncomplete},
|
||||
{ActionReasonIllogical, feedbackdomain.ReasonIllogical},
|
||||
{ActionReasonFactualError, feedbackdomain.ReasonFactualError},
|
||||
{ActionReasonSensitiveContent, feedbackdomain.ReasonSensitiveContent},
|
||||
{ActionReasonAnswerNotWanted, feedbackdomain.ReasonAnswerNotWanted},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.action, func(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: tc.reason}}
|
||||
handler, _ := NewCardFeedbackHandler(service)
|
||||
response, err := handler.HandleSDK(context.Background(), cardRequest(tc.action))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.calls != 1 || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != tc.reason {
|
||||
t.Fatalf("calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
if response == nil || response.UserPrivateData == nil || response.UserPrivateData.CardParamMap["feedbackReasonSelected"] != tc.reason {
|
||||
t.Fatalf("response=%+v", response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardFeedbackMapsWhitelistedActions(t *testing.T) {
|
||||
tests := []struct {
|
||||
action string
|
||||
@@ -147,6 +296,43 @@ func TestStreamClientRegistersOptionalCardFeedbackRouter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCardRouterHandlesNativeSDKDataFrameEnvelope(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Changed: true, Value: feedbackdomain.ValueDown, ReasonCode: feedbackdomain.ReasonContentMismatch}}
|
||||
feedbackHandler, _ := NewCardFeedbackHandler(service)
|
||||
client, err := NewStreamClient(
|
||||
StreamConfig{ClientID: "id", ClientSecret: "secret", TenantKey: "tenant"},
|
||||
mustTestHandler(t), feedbackHandler,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router, err := client.inner.GetHandler(utils.SubscriptionTypeKCallback, payload.CardInstanceCallbackTopic)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actionData, _ := json.Marshal(map[string]any{"cardPrivateData": map[string]any{
|
||||
"actionIds": []string{ActionNativeFeedback},
|
||||
"params": map[string]any{
|
||||
"variableKey": "node_ocmrjfmlsra_dislikeTags",
|
||||
"variableValue": []string{"1"},
|
||||
},
|
||||
}})
|
||||
outer, _ := json.Marshal(map[string]any{
|
||||
"outTrackId": "track-native-frame", "userId": "user-native-frame", "content": string(actionData),
|
||||
"corpId": "must-not-be-used", "extension": "must-not-be-used",
|
||||
})
|
||||
frameResponse, err := router(context.Background(), &payload.DataFrame{Data: string(outer)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frameResponse == nil || frameResponse.Code != payload.DataFrameResponseStatusCodeKOK {
|
||||
t.Fatalf("frame response=%+v", frameResponse)
|
||||
}
|
||||
if service.calls != 1 || service.command.ExternalActorID != "user-native-frame" || service.command.OutTrackID != "track-native-frame" || service.command.Value != feedbackdomain.ValueDown || service.command.ReasonCode != feedbackdomain.ReasonContentMismatch {
|
||||
t.Fatalf("service calls=%d command=%+v", service.calls, service.command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCardRouterHandlesRealSDKDataFrameEnvelope(t *testing.T) {
|
||||
service := &fakeFeedbackService{result: feedbackdomain.Result{Created: true, Value: feedbackdomain.ValueDown, ReasonCode: feedbackdomain.ReasonNotResolved}}
|
||||
feedbackHandler, _ := NewCardFeedbackHandler(service)
|
||||
|
||||
@@ -117,7 +117,8 @@ func (s *Service) Apply(ctx context.Context, cmd Command) (Result, error) {
|
||||
|
||||
func validReason(reason string) bool {
|
||||
switch reason {
|
||||
case ReasonEmpty, ReasonIncorrect, ReasonNotResolved, ReasonIrrelevant, ReasonUnclear, ReasonOutdated, ReasonOther:
|
||||
case ReasonEmpty, ReasonIncorrect, ReasonNotResolved, ReasonIrrelevant, ReasonUnclear, ReasonOutdated, ReasonOther,
|
||||
ReasonContentMismatch, ReasonContentIncomplete, ReasonIllogical, ReasonFactualError, ReasonSensitiveContent, ReasonAnswerNotWanted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -92,6 +92,8 @@ func TestApplyDownAcceptsEveryReasonAndEmpty(t *testing.T) {
|
||||
reasons := []string{
|
||||
feedback.ReasonEmpty, feedback.ReasonIncorrect, feedback.ReasonNotResolved,
|
||||
feedback.ReasonIrrelevant, feedback.ReasonUnclear, feedback.ReasonOutdated, feedback.ReasonOther,
|
||||
feedback.ReasonContentMismatch, feedback.ReasonContentIncomplete, feedback.ReasonIllogical,
|
||||
feedback.ReasonFactualError, feedback.ReasonSensitiveContent, feedback.ReasonAnswerNotWanted,
|
||||
}
|
||||
for _, reason := range reasons {
|
||||
t.Run("reason_"+reason, func(t *testing.T) {
|
||||
|
||||
@@ -25,13 +25,19 @@ const (
|
||||
|
||||
// ReasonCode constants for down feedback.
|
||||
const (
|
||||
ReasonEmpty = ""
|
||||
ReasonIncorrect = "incorrect"
|
||||
ReasonNotResolved = "not_resolved"
|
||||
ReasonIrrelevant = "irrelevant"
|
||||
ReasonUnclear = "unclear"
|
||||
ReasonOutdated = "outdated"
|
||||
ReasonOther = "other"
|
||||
ReasonEmpty = ""
|
||||
ReasonIncorrect = "incorrect"
|
||||
ReasonNotResolved = "not_resolved"
|
||||
ReasonIrrelevant = "irrelevant"
|
||||
ReasonUnclear = "unclear"
|
||||
ReasonOutdated = "outdated"
|
||||
ReasonOther = "other"
|
||||
ReasonContentMismatch = "content_mismatch"
|
||||
ReasonContentIncomplete = "content_incomplete"
|
||||
ReasonIllogical = "illogical_expression"
|
||||
ReasonFactualError = "factual_error"
|
||||
ReasonSensitiveContent = "sensitive_content"
|
||||
ReasonAnswerNotWanted = "answer_not_wanted"
|
||||
)
|
||||
|
||||
// Source constants.
|
||||
|
||||
Reference in New Issue
Block a user