feat: add FAQ uncovered workflow

This commit is contained in:
姚章浩
2026-07-27 11:54:34 +08:00
parent e582d851a4
commit aa0513eb59
28 changed files with 1319 additions and 274 deletions
+10 -1
View File
@@ -27,10 +27,19 @@ DINGTALK_CARD_TEMPLATE_ID=
# Keep empty outside a controlled debug session; never commit a real user ID.
DINGTALK_CARD_DEBUG_USER_ID=
# Optional DingTalk FAQ table workflow for uncovered questions. Set all four
# values together; leave all four empty to disable. Required when the support
# group notification below is enabled. Never commit real IDs.
DINGTALK_FAQ_TABLE_BASE_ID=
DINGTALK_FAQ_TABLE_SHEET_ID=
DINGTALK_FAQ_TABLE_VIEW_ID=
DINGTALK_FAQ_TABLE_OPERATOR_ID=
# Optional support group for uncovered-answer notifications. This is the
# group's openConversationId, not a webhook. Leave empty to record coverage
# decisions and pending support cases without sending group notifications.
# Never commit a real group ID.
# When enabled, the FAQ table workflow above must also be configured so the
# ActionCard can link to the exact FAQ record. Never commit a real group ID.
DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID=
# Independent DobotAI PostgreSQL; never point this at WeKnora's database
+14 -1
View File
@@ -26,7 +26,7 @@ Implemented:
- Sessions: direct chats use one long-lived session per user; group chats share one session per group (default `shared` policy).
- Replies are sent via the callback's `sessionWebhook`, which is fail-closed: only `https` URLs on `*.dingtalk.com` (port 443, no redirects) are accepted.
- Required env vars to enable (all three or none): `DINGTALK_CLIENT_ID`, `DINGTALK_CLIENT_SECRET`, `DOBOTAI_TENANT_KEY`. Partial configuration is a fatal startup error.
- Optional `DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID` enables durable uncovered-answer notifications through the official group-message API. When empty, coverage decisions and pending support cases are still recorded but no group message is sent.
- Optional `DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID` enables durable uncovered-answer ActionCard notifications through the official group-message API. It requires the FAQ table workflow below so every “去回答” button targets an exact record. When empty, coverage decisions and support cases are still recorded but no group message is sent.
## DingTalk question table sync (Notable v1)
@@ -40,6 +40,19 @@ Apply `db/migrations/000010_question_table_exports.up.sql` before enabling the w
The worker reuses `DINGTALK_CLIENT_ID` and `DINGTALK_CLIENT_SECRET`. The DingTalk application must have `Notable.Base.Read.All` and `Notable.Base.Write.All`. Only successful `channel=dingtalk` turns are enqueued; other channels are excluded. The first release writes the nine basic text/date/user fields and omits the five select-classification fields.
## Uncovered-question FAQ workflow
Apply `db/migrations/000011_faq_exports.up.sql` before enabling this workflow. Existing support cases are marked `not_applicable` and are not backfilled; only support cases created after the migration default to `pending` FAQ export.
Set all four values together, or leave all four blank to disable FAQ export. Partial configuration fails startup closed:
- `DINGTALK_FAQ_TABLE_BASE_ID`
- `DINGTALK_FAQ_TABLE_SHEET_ID`
- `DINGTALK_FAQ_TABLE_VIEW_ID`
- `DINGTALK_FAQ_TABLE_OPERATOR_ID` (DingTalk unionId)
For every newly detected uncovered answer, the asynchronous worker creates one FAQ record with standard question, original question, detected language, and asker. After DingTalk returns the `recordId`, DobotAI stores it and sends a built-in `sampleActionCard` containing only the current question and up to five earlier questions from the same stable actor. The “去回答” button opens that exact FAQ record. FAQ insert failures are retried with bounded backoff and never block the customer reply.
## WeKnora baseline
Target policy: latest stable official Release/Tag. The newest locally fetched stable tag is `v0.6.3`. Online GitHub verification is pending because DNS/proxy access was unavailable during initialization.
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"context"
"errors"
"log"
"os"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/questiontable"
)
type faqConfig struct {
BaseID string
SheetID string
ViewID string
OperatorID string
}
func readFAQConfig() (faqConfig, error) {
cfg := faqConfig{
BaseID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_BASE_ID")),
SheetID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_SHEET_ID")),
ViewID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_VIEW_ID")),
OperatorID: strings.TrimSpace(os.Getenv("DINGTALK_FAQ_TABLE_OPERATOR_ID")),
}
blank := cfg.BaseID == "" && cfg.SheetID == "" && cfg.ViewID == "" && cfg.OperatorID == ""
complete := cfg.BaseID != "" && cfg.SheetID != "" && cfg.ViewID != "" && cfg.OperatorID != ""
if blank {
return faqConfig{}, nil
}
if !complete {
return faqConfig{}, errors.New("DingTalk FAQ table config must be complete or disabled")
}
return cfg, nil
}
func buildFAQDispatcher(repo faqtable.ExportRepository, clientID, clientSecret string, cfg faqConfig) (*faqtable.Dispatcher, error) {
if cfg.BaseID == "" {
return nil, nil
}
if repo == nil {
return nil, errors.New("FAQ dispatcher repository is required")
}
client, err := questiontable.NewClient(questiontable.Config{
ClientID: clientID, Secret: clientSecret, BaseID: cfg.BaseID,
SheetID: cfg.SheetID, OperatorID: cfg.OperatorID,
}, nil)
if err != nil {
return nil, err
}
return faqtable.NewDispatcher(repo, client, nil)
}
type faqPollerRunner interface {
RunOnce(context.Context) (faqtable.DispatcherStats, error)
}
func runFAQPoller(ctx context.Context, runner faqPollerRunner, interval time.Duration) {
if runner == nil || interval <= 0 {
return
}
run := func() {
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
stats, err := runner.RunOnce(runCtx)
cancel()
if err != nil {
log.Printf("FAQ table dispatch failed")
return
}
if stats.Claimed > 0 {
log.Printf("FAQ table dispatch: claimed=%d exported=%d failed=%d", stats.Claimed, stats.Exported, stats.Failed)
}
}
if ctx.Err() != nil {
return
}
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
type faqRepoStub struct{}
func (*faqRepoStub) Claim(context.Context, int, int, time.Time) ([]faqtable.PendingExport, error) {
return nil, nil
}
func (*faqRepoStub) MarkExported(context.Context, string, string) error { return nil }
func (*faqRepoStub) MarkFailed(context.Context, string, string) error { return nil }
func TestReadFAQConfigDisabledWhenAllBlank(t *testing.T) {
for _, key := range []string{"DINGTALK_FAQ_TABLE_BASE_ID", "DINGTALK_FAQ_TABLE_SHEET_ID", "DINGTALK_FAQ_TABLE_VIEW_ID", "DINGTALK_FAQ_TABLE_OPERATOR_ID"} {
t.Setenv(key, "")
}
cfg, err := readFAQConfig()
if err != nil || cfg.BaseID != "" {
t.Fatalf("cfg=%+v err=%v", cfg, err)
}
}
func TestReadFAQConfigRejectsPartialValues(t *testing.T) {
t.Setenv("DINGTALK_FAQ_TABLE_BASE_ID", "base")
t.Setenv("DINGTALK_FAQ_TABLE_SHEET_ID", "")
t.Setenv("DINGTALK_FAQ_TABLE_VIEW_ID", "view")
t.Setenv("DINGTALK_FAQ_TABLE_OPERATOR_ID", "operator")
if _, err := readFAQConfig(); err == nil {
t.Fatal("expected partial-config error")
}
}
func TestBuildFAQDispatcherRequiresRepositoryWhenEnabled(t *testing.T) {
cfg := faqConfig{BaseID: "base", SheetID: "sheet", ViewID: "view", OperatorID: "operator"}
if _, err := buildFAQDispatcher(nil, "client", "secret", cfg); err == nil {
t.Fatal("expected repository error")
}
if d, err := buildFAQDispatcher(&faqRepoStub{}, "client", "secret", cfg); err != nil || d == nil {
t.Fatalf("dispatcher=%v err=%v", d, err)
}
}
func TestMainWiresFAQExportBeforeSupportNotification(t *testing.T) {
data, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
source := string(data)
for _, fragment := range []string{
"readFAQConfig()", "postgres.NewFAQExportRepository(store)",
"buildFAQDispatcher(", "runFAQPoller(ctx, faqDispatcher, 5*time.Second)",
"faqtable.NewRecordLinkBuilder(", "buildSupportDispatcher(",
} {
if !strings.Contains(source, fragment) {
t.Errorf("main FAQ wiring missing %q", fragment)
}
}
if strings.Index(source, "runFAQPoller(ctx") > strings.Index(source, "runSupportDispatcher(ctx") {
t.Fatal("FAQ exporter must start before support notifications")
}
}
func TestEnvExampleDocumentsFAQWorkflowConfig(t *testing.T) {
data, err := os.ReadFile("../../.env.example")
if err != nil {
t.Fatal(err)
}
for _, key := range []string{"DINGTALK_FAQ_TABLE_BASE_ID", "DINGTALK_FAQ_TABLE_SHEET_ID", "DINGTALK_FAQ_TABLE_VIEW_ID", "DINGTALK_FAQ_TABLE_OPERATOR_ID"} {
if !strings.Contains(string(data), key+"=") {
t.Errorf(".env.example missing %s", key)
}
}
}
+36
View File
@@ -16,6 +16,7 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/directory"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/feedback"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/httpapi"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/qa"
@@ -86,11 +87,39 @@ func main() {
if err := dingCfg.Validate(); err != nil {
log.Fatal(err)
}
faqCfg, err := readFAQConfig()
if err != nil {
log.Fatal(err)
}
var faqWorkflowDone chan struct{}
var faqRecordLinks *faqtable.RecordLinkBuilder
if faqCfg.BaseID != "" {
faqRecordLinks, err = faqtable.NewRecordLinkBuilder(faqCfg.BaseID, faqCfg.SheetID, faqCfg.ViewID)
if err != nil {
log.Fatal(err)
}
faqRepo, err := postgres.NewFAQExportRepository(store)
if err != nil {
log.Fatal(err)
}
faqDispatcher, err := buildFAQDispatcher(faqRepo, dingCfg.ClientID, dingCfg.ClientSecret, faqCfg)
if err != nil {
log.Fatalf("DingTalk FAQ table dispatcher init failed: %v", err)
}
faqWorkflowDone = make(chan struct{})
go func() {
defer close(faqWorkflowDone)
runFAQPoller(ctx, faqDispatcher, 5*time.Second)
}()
log.Printf("DingTalk FAQ table dispatcher started")
}
supportDispatcher, err := buildSupportDispatcher(
store,
dingCfg.ClientID,
dingCfg.ClientSecret,
os.Getenv("DINGTALK_SUPPORT_GROUP_OPEN_CONVERSATION_ID"),
faqRecordLinks,
)
if err != nil {
log.Fatalf("DingTalk support notification init failed: %v", err)
@@ -204,6 +233,13 @@ func main() {
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP shutdown: %v", err)
}
if faqWorkflowDone != nil {
select {
case <-faqWorkflowDone:
case <-shutdownCtx.Done():
log.Printf("FAQ table poller shutdown timed out")
}
}
if questionTableDone != nil {
select {
case <-questionTableDone:
+6 -2
View File
@@ -9,13 +9,14 @@ import (
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/channels/dingtalk"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
type supportDispatcherRunner interface {
RunOnce(context.Context) (coverage.DispatcherStats, error)
}
func buildSupportDispatcher(repo coverage.NotificationRepository, clientID, clientSecret, openConversationID string) (*coverage.Dispatcher, error) {
func buildSupportDispatcher(repo coverage.NotificationRepository, clientID, clientSecret, openConversationID string, recordLinks *faqtable.RecordLinkBuilder) (*coverage.Dispatcher, error) {
openConversationID = strings.TrimSpace(openConversationID)
if openConversationID == "" {
return nil, nil
@@ -23,8 +24,11 @@ func buildSupportDispatcher(repo coverage.NotificationRepository, clientID, clie
if repo == nil {
return nil, errors.New("support notification repository is required")
}
if recordLinks == nil {
return nil, errors.New("support notification FAQ record links are required")
}
sender, err := dingtalk.NewSupportGroupSender(clientID, clientSecret, openConversationID, nil)
sender, err := dingtalk.NewSupportGroupSender(clientID, clientSecret, openConversationID, nil, dingtalk.WithFAQRecordLinkBuilder(recordLinks))
if err != nil {
return nil, err
}
+19 -6
View File
@@ -10,6 +10,7 @@ import (
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
type supportNotificationRepoStub struct{}
@@ -20,6 +21,15 @@ func (*supportNotificationRepoStub) Claim(context.Context, int, int, time.Time)
func (*supportNotificationRepoStub) MarkNotified(context.Context, string) error { return nil }
func (*supportNotificationRepoStub) MarkFailed(context.Context, string, string) error { return nil }
func testFAQRecordLinks(t *testing.T) *faqtable.RecordLinkBuilder {
t.Helper()
builder, err := faqtable.NewRecordLinkBuilder("base", "sheet", "view")
if err != nil {
t.Fatal(err)
}
return builder
}
type supportDispatcherStub struct {
mu sync.Mutex
calls int
@@ -46,7 +56,7 @@ func (s *supportDispatcherStub) RunOnce(context.Context) (coverage.DispatcherSta
}
func TestBuildSupportDispatcherDisabledWithoutGroup(t *testing.T) {
dispatcher, err := buildSupportDispatcher(nil, "", "", " ")
dispatcher, err := buildSupportDispatcher(nil, "", "", " ", nil)
if err != nil || dispatcher != nil {
t.Fatalf("dispatcher=%v err=%v, want disabled", dispatcher, err)
}
@@ -54,17 +64,20 @@ func TestBuildSupportDispatcherDisabledWithoutGroup(t *testing.T) {
func TestBuildSupportDispatcherEnabledFailsClosed(t *testing.T) {
repo := &supportNotificationRepoStub{}
links := testFAQRecordLinks(t)
for _, tc := range []struct {
name string
repo coverage.NotificationRepository
clientID, secret, gid string
links *faqtable.RecordLinkBuilder
}{
{name: "missing repo", clientID: "client", secret: "secret", gid: "group"},
{name: "missing client", repo: repo, secret: "secret", gid: "group"},
{name: "missing secret", repo: repo, clientID: "client", gid: "group"},
{name: "missing repo", clientID: "client", secret: "secret", gid: "group", links: links},
{name: "missing client", repo: repo, secret: "secret", gid: "group", links: links},
{name: "missing secret", repo: repo, clientID: "client", gid: "group", links: links},
{name: "missing FAQ links", repo: repo, clientID: "client", secret: "secret", gid: "group"},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := buildSupportDispatcher(tc.repo, tc.clientID, tc.secret, tc.gid); err == nil {
if _, err := buildSupportDispatcher(tc.repo, tc.clientID, tc.secret, tc.gid, tc.links); err == nil {
t.Fatal("expected configuration error")
}
})
@@ -72,7 +85,7 @@ func TestBuildSupportDispatcherEnabledFailsClosed(t *testing.T) {
}
func TestBuildSupportDispatcherValidDoesNotSend(t *testing.T) {
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, " client ", " secret ", " group ")
dispatcher, err := buildSupportDispatcher(&supportNotificationRepoStub{}, " client ", " secret ", " group ", testFAQRecordLinks(t))
if err != nil {
t.Fatalf("buildSupportDispatcher: %v", err)
}
+11
View File
@@ -0,0 +1,11 @@
DROP INDEX IF EXISTS idx_support_cases_faq_export_stale;
DROP INDEX IF EXISTS idx_support_cases_faq_export_claim;
ALTER TABLE support_cases
DROP COLUMN IF EXISTS faq_record_id,
DROP COLUMN IF EXISTS faq_failure_code,
DROP COLUMN IF EXISTS faq_exported_at,
DROP COLUMN IF EXISTS faq_next_attempt_at,
DROP COLUMN IF EXISTS faq_last_attempt_at,
DROP COLUMN IF EXISTS faq_export_attempts,
DROP COLUMN IF EXISTS faq_export_status;
+24
View File
@@ -0,0 +1,24 @@
-- Durable FAQ export state for uncovered support cases.
ALTER TABLE support_cases
ADD COLUMN IF NOT EXISTS faq_export_status TEXT NOT NULL DEFAULT 'not_applicable'
CHECK (faq_export_status IN ('not_applicable', 'pending', 'exporting', 'exported', 'export_failed')),
ADD COLUMN IF NOT EXISTS faq_export_attempts INTEGER NOT NULL DEFAULT 0
CHECK (faq_export_attempts >= 0),
ADD COLUMN IF NOT EXISTS faq_last_attempt_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS faq_next_attempt_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS faq_exported_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS faq_failure_code TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS faq_record_id TEXT NOT NULL DEFAULT '';
-- Existing cases are intentionally not backfilled. Only cases created after
-- this migration enter the FAQ workflow automatically.
ALTER TABLE support_cases
ALTER COLUMN faq_export_status SET DEFAULT 'pending';
CREATE INDEX IF NOT EXISTS idx_support_cases_faq_export_claim
ON support_cases (faq_export_status, faq_next_attempt_at, created_at, id)
WHERE faq_export_status IN ('pending', 'export_failed');
CREATE INDEX IF NOT EXISTS idx_support_cases_faq_export_stale
ON support_cases (faq_export_status, faq_last_attempt_at)
WHERE faq_export_status = 'exporting';
@@ -0,0 +1,74 @@
package migrations_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func read011Migration(t *testing.T, direction string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(".", "000011_faq_exports."+direction+".sql"))
if err != nil {
t.Fatalf("read 000011 %s migration: %v", direction, err)
}
return strings.ToUpper(string(data))
}
func TestUp011AddsDurableFAQExportStateToSupportCases(t *testing.T) {
up := read011Migration(t, "up")
for _, fragment := range []string{
"ALTER TABLE SUPPORT_CASES",
"FAQ_EXPORT_STATUS", "FAQ_EXPORT_ATTEMPTS", "FAQ_LAST_ATTEMPT_AT",
"FAQ_NEXT_ATTEMPT_AT", "FAQ_EXPORTED_AT", "FAQ_FAILURE_CODE", "FAQ_RECORD_ID",
"PENDING", "EXPORTING", "EXPORTED", "EXPORT_FAILED",
} {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing %q", fragment)
}
}
for _, forbidden := range []string{"FAQ_QUESTION", "FAQ_ANSWER", "RAW_RESPONSE", "ACCESS_TOKEN"} {
if strings.Contains(up, forbidden) {
t.Errorf("migration must not duplicate business or secret field %q", forbidden)
}
}
}
func TestUp011DoesNotBackfillHistoricalSupportCases(t *testing.T) {
up := read011Migration(t, "up")
if !strings.Contains(up, "DEFAULT 'NOT_APPLICABLE'") {
t.Fatal("existing support cases must start as not_applicable")
}
if !strings.Contains(up, "ALTER COLUMN FAQ_EXPORT_STATUS SET DEFAULT 'PENDING'") {
t.Fatal("future support cases must default to pending")
}
}
func TestUp011AddsBoundedClaimIndexes(t *testing.T) {
up := read011Migration(t, "up")
for _, fragment := range []string{
"IDX_SUPPORT_CASES_FAQ_EXPORT_CLAIM",
"FAQ_NEXT_ATTEMPT_AT", "FAQ_LAST_ATTEMPT_AT",
"WHERE FAQ_EXPORT_STATUS IN ('PENDING', 'EXPORT_FAILED')",
} {
if !strings.Contains(up, fragment) {
t.Errorf("up migration missing claim index fragment %q", fragment)
}
}
}
func TestDown011OnlyRemovesFAQExportState(t *testing.T) {
down := read011Migration(t, "down")
for _, column := range []string{
"FAQ_EXPORT_STATUS", "FAQ_EXPORT_ATTEMPTS", "FAQ_LAST_ATTEMPT_AT",
"FAQ_NEXT_ATTEMPT_AT", "FAQ_EXPORTED_AT", "FAQ_FAILURE_CODE", "FAQ_RECORD_ID",
} {
if !strings.Contains(down, "DROP COLUMN IF EXISTS "+column) {
t.Errorf("down migration missing %s", column)
}
}
if strings.Contains(down, "DROP TABLE") {
t.Fatal("down migration must not drop baseline tables")
}
}
-4
View File
@@ -79,10 +79,6 @@ func (h *CardFeedbackHandler) HandleSDK(ctx context.Context, request *sdkcard.Ca
if value == feedbackdomain.ValueUp {
message = "感谢反馈"
}
if value == feedbackdomain.ValueDown && reason == feedbackdomain.ReasonEmpty {
showReasons = "true"
message = "请选择未解决原因"
}
return &sdkcard.CardResponse{
CardUpdateOptions: &sdkcard.CardUpdateOptions{UpdatePrivateDataByKey: true},
UserPrivateData: &sdkcard.CardDataDto{CardParamMap: map[string]string{
+4 -2
View File
@@ -84,7 +84,9 @@ func TestCardFeedbackMapsNativeDislike(t *testing.T) {
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" {
if response == nil || response.UserPrivateData == nil ||
response.UserPrivateData.CardParamMap["showFeedbackReasons"] != "false" ||
response.UserPrivateData.CardParamMap["feedbackMessage"] != "感谢反馈,我们会继续改进" {
t.Fatalf("response=%+v", response)
}
}
@@ -201,7 +203,7 @@ func TestCardFeedbackMapsWhitelistedActions(t *testing.T) {
message string
}{
{ActionFeedbackUp, feedbackdomain.ValueUp, "", "false", "感谢反馈"},
{ActionFeedbackDown, feedbackdomain.ValueDown, "", "true", "请选择未解决原因"},
{ActionFeedbackDown, feedbackdomain.ValueDown, "", "false", "感谢反馈,我们会继续改进"},
{ActionReasonIncorrect, feedbackdomain.ValueDown, feedbackdomain.ReasonIncorrect, "false", "感谢反馈,我们会继续改进"},
{ActionReasonNotResolved, feedbackdomain.ValueDown, feedbackdomain.ReasonNotResolved, "false", "感谢反馈,我们会继续改进"},
{ActionReasonIrrelevant, feedbackdomain.ValueDown, feedbackdomain.ReasonIrrelevant, "false", "感谢反馈,我们会继续改进"},
+72 -33
View File
@@ -12,8 +12,8 @@ import (
"sync"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
const (
@@ -21,10 +21,10 @@ const (
// maxGroupPayloadBytes caps the serialized JSON body sent to the group
// message endpoint.
maxGroupPayloadBytes = 64 << 10 // 64 KiB
// questionRuneLimit caps the displayed question in the markdown body.
// questionRuneLimit caps the displayed current question.
questionRuneLimit = 500
// answerRuneLimit caps the displayed answer in the markdown body.
answerRuneLimit = 1000
// previousQuestionRuneLimit caps each displayed historical question.
previousQuestionRuneLimit = 300
)
// SupportGroupSender sends DingTalk group Markdown notifications via the
@@ -35,6 +35,7 @@ type SupportGroupSender struct {
clientID string
clientSecret string
openConversation string
recordLinks *faqtable.RecordLinkBuilder
tokenURL string
groupURL string
apiClient *http.Client
@@ -44,21 +45,27 @@ type SupportGroupSender struct {
tokenTill time.Time
}
type SupportGroupSenderOption func(*SupportGroupSender)
func WithFAQRecordLinkBuilder(builder *faqtable.RecordLinkBuilder) SupportGroupSenderOption {
return func(sender *SupportGroupSender) { sender.recordLinks = builder }
}
var _ coverage.Notifier = (*SupportGroupSender)(nil)
// NewSupportGroupSender creates a SupportGroupSender. All three config values
// are required after trimming. A nil http.Client installs a hardened default
// (no redirects, Proxy=nil, 15s timeout).
func NewSupportGroupSender(clientID, clientSecret, openConversationID string, client *http.Client) (*SupportGroupSender, error) {
return newSupportGroupSender(clientID, clientSecret, openConversationID, client, oauthTokenURL, groupSendURL, false)
func NewSupportGroupSender(clientID, clientSecret, openConversationID string, client *http.Client, opts ...SupportGroupSenderOption) (*SupportGroupSender, error) {
return newSupportGroupSender(clientID, clientSecret, openConversationID, client, oauthTokenURL, groupSendURL, false, opts...)
}
// newSupportGroupSenderForTest is used by tests to inject httptest URLs.
func newSupportGroupSenderForTest(clientID, clientSecret, openConversationID string, client *http.Client, tokenURL, groupURL string) (*SupportGroupSender, error) {
return newSupportGroupSender(clientID, clientSecret, openConversationID, client, tokenURL, groupURL, true)
func newSupportGroupSenderForTest(clientID, clientSecret, openConversationID string, client *http.Client, tokenURL, groupURL string, opts ...SupportGroupSenderOption) (*SupportGroupSender, error) {
return newSupportGroupSender(clientID, clientSecret, openConversationID, client, tokenURL, groupURL, true, opts...)
}
func newSupportGroupSender(clientID, clientSecret, openConversationID string, client *http.Client, tokenURL, groupURL string, allowTest bool) (*SupportGroupSender, error) {
func newSupportGroupSender(clientID, clientSecret, openConversationID string, client *http.Client, tokenURL, groupURL string, allowTest bool, opts ...SupportGroupSenderOption) (*SupportGroupSender, error) {
clientID = strings.TrimSpace(clientID)
clientSecret = strings.TrimSpace(clientSecret)
openConversationID = strings.TrimSpace(openConversationID)
@@ -91,14 +98,23 @@ func newSupportGroupSender(clientID, clientSecret, openConversationID string, cl
apiClient.Transport = transport
}
return &SupportGroupSender{
sender := &SupportGroupSender{
clientID: clientID,
clientSecret: clientSecret,
openConversation: openConversationID,
tokenURL: tokenURL,
groupURL: groupURL,
apiClient: &apiClient,
}, nil
}
for _, opt := range opts {
if opt != nil {
opt(sender)
}
}
if allowTest && sender.recordLinks == nil {
sender.recordLinks, _ = faqtable.NewRecordLinkBuilder("test-base", "test-sheet", "test-view")
}
return sender, nil
}
// Send sends a support-case notification to the DingTalk group.
@@ -106,12 +122,20 @@ func (s *SupportGroupSender) Send(ctx context.Context, notif coverage.SupportNot
if notif.CaseID == "" {
return errors.New("dingtalk group sender: CaseID is required")
}
if s.recordLinks == nil {
return errors.New("dingtalk group sender: FAQ record links are required")
}
recordURL, err := s.recordLinks.Build(notif.FAQRecordID)
if err != nil {
return errors.New("dingtalk group sender: FAQ record link failed")
}
// Build the markdown notification body
title, text := s.renderMarkdown(notif)
msgParam := map[string]string{
"title": title,
"text": text,
"title": title,
"text": text,
"singleTitle": "去回答",
"singleURL": recordURL,
}
msgParamJSON, err := json.Marshal(msgParam)
if err != nil {
@@ -119,7 +143,7 @@ func (s *SupportGroupSender) Send(ctx context.Context, notif coverage.SupportNot
}
body := map[string]any{
"msgKey": "sampleMarkdown",
"msgKey": "sampleActionCard",
"msgParam": string(msgParamJSON),
"openConversationId": s.openConversation,
"robotCode": s.clientID,
@@ -163,31 +187,46 @@ func (s *SupportGroupSender) Send(ctx context.Context, notif coverage.SupportNot
return nil
}
// renderMarkdown produces a title and text for the group notification.
// renderMarkdown produces the compact customer-question context shown in the action card.
func (s *SupportGroupSender) renderMarkdown(notif coverage.SupportNotification) (title, text string) {
title = fmt.Sprintf("📋 知识缺失通知 #%s", notif.CaseID)
convType := "群聊"
if notif.ConversationType == core.ConversationDirect {
convType = "单聊"
title = "知识库未覆盖提醒"
question := escapeMarkdown(truncateRunes(notif.Question, questionRuneLimit))
sender := escapeMarkdown(notif.SenderName)
askedAt := "未知"
if !notif.AskedAt.IsZero() {
askedAt = notif.AskedAt.In(time.FixedZone("CST", 8*60*60)).Format("2006-01-02 15:04:05")
}
question := truncateRunes(notif.Question, questionRuneLimit)
answer := truncateRunes(notif.Answer, answerRuneLimit)
var b strings.Builder
b.WriteString("## 📋 知识缺失通知\n\n")
b.WriteString(fmt.Sprintf("**Case ID:** %s\n\n", notif.CaseID))
b.WriteString(fmt.Sprintf("**提问人:** %s\n\n", notif.SenderName))
b.WriteString(fmt.Sprintf("**渠道/会话类型:** %s / %s\n\n", notif.Channel, convType))
b.WriteString(fmt.Sprintf("**问题摘要:**\n> %s\n\n", question))
b.WriteString(fmt.Sprintf("**回答摘要:**\n> %s\n\n", answer))
b.WriteString(fmt.Sprintf("**相关结果数:** %d\n\n", notif.ReferenceCount))
b.WriteString(fmt.Sprintf("**最高Score:** %.4f\n", notif.TopScore))
b.WriteString("## 【知识库未覆盖提醒】\n\n")
b.WriteString(fmt.Sprintf("**提问人:** %s\n\n", sender))
b.WriteString(fmt.Sprintf("**时间:** %s\n\n", askedAt))
b.WriteString(fmt.Sprintf("**当前问题:**\n> %s\n\n", question))
b.WriteString(fmt.Sprintf("**语言:** %s\n\n", faqtable.DetectLanguage(notif.Question)))
b.WriteString("**最近5次历史问题:**\n")
if len(notif.PreviousQuestions) == 0 {
b.WriteString("暂无历史问题\n")
} else {
for i, previous := range notif.PreviousQuestions {
if i >= 5 {
break
}
b.WriteString(fmt.Sprintf("%d. %s\n", i+1, escapeMarkdown(truncateRunes(previous, previousQuestionRuneLimit))))
}
}
return title, b.String()
}
func escapeMarkdown(value string) string {
value = strings.Join(strings.Fields(value), " ")
replacer := strings.NewReplacer(
"\\", "\\\\", "`", "\\`", "*", "\\*", "_", "\\_",
"[", "\\[", "]", "\\]", "(", "\\(", ")", "\\)",
"#", "\\#", ">", "\\>",
)
return replacer.Replace(value)
}
func truncateRunes(s string, limit int) string {
if limit <= 0 {
return s
@@ -0,0 +1,60 @@
package dingtalk
import (
"context"
"encoding/json"
"net/url"
"strings"
"testing"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
func TestSupportGroupSenderSendsActionCardWithExactFAQRecordLink(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
builder, err := faqtable.NewRecordLinkBuilder("jb9-base", "hERWDMS", "qvGDAH2")
if err != nil {
t.Fatal(err)
}
s, err := newSupportGroupSenderForTest(
"appKey", "appSecret", "cid-abc", gs.server.Client(),
gs.URL()+"/token", gs.URL()+"/groupMessages/send",
WithFAQRecordLinkBuilder(builder),
)
if err != nil {
t.Fatal(err)
}
if err := s.Send(context.Background(), newTestNotification()); err != nil {
t.Fatal(err)
}
if gs.lastSendBody["msgKey"] != "sampleActionCard" {
t.Fatalf("msgKey=%v", gs.lastSendBody["msgKey"])
}
var params map[string]string
if err := json.Unmarshal([]byte(gs.lastSendBody["msgParam"].(string)), &params); err != nil {
t.Fatal(err)
}
if params["singleTitle"] != "去回答" {
t.Fatalf("singleTitle=%q", params["singleTitle"])
}
parsed, err := url.Parse(params["singleURL"])
if err != nil {
t.Fatal(err)
}
q := parsed.Query()
if parsed.Host != "docs.dingtalk.com" || q.Get("rowId") != "record-001" || q.Get("dentryUuid") != "jb9-base" {
t.Fatalf("singleURL=%s", params["singleURL"])
}
text := params["text"]
for _, required := range []string{"张三", "2026-07-24 10:16:35", "怎么配置钉钉机器人", "之前的问题一", "之前的问题二", "Chinese"} {
if !strings.Contains(text, required) {
t.Errorf("text missing %q: %s", required, text)
}
}
for _, forbidden := range []string{"回答摘要", "相关结果", "Score", "置信度", "case-001"} {
if strings.Contains(text, forbidden) {
t.Errorf("text contains forbidden %q: %s", forbidden, text)
}
}
}
+33 -110
View File
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/coverage"
@@ -73,14 +74,14 @@ func (gs *groupTestServer) URL() string { return gs.server.URL }
func newTestNotification() coverage.SupportNotification {
return coverage.SupportNotification{
CaseID: "case-001",
Question: "怎么配置钉钉机器人?",
Answer: "进入钉钉开放平台,创建应用并获取AppKey和AppSecret。",
SenderName: "张三",
Channel: "dingtalk",
ConversationType: core.ConversationGroup,
ReferenceCount: 3,
TopScore: 0.95,
CaseID: "case-001",
Question: "怎么配置钉钉机器人?",
SenderName: "张三",
Channel: "dingtalk",
ConversationType: core.ConversationGroup,
AskedAt: time.Date(2026, 7, 24, 10, 16, 35, 0, time.FixedZone("CST", 8*60*60)),
PreviousQuestions: []string{"之前的问题一", "之前的问题二"},
FAQRecordID: "record-001",
}
}
@@ -150,8 +151,8 @@ func TestSupportGroupSenderExactEndpointBodyHeader(t *testing.T) {
}
body := gs.lastSendBody
if body["msgKey"] != "sampleMarkdown" {
t.Fatalf("msgKey = %v, want sampleMarkdown", body["msgKey"])
if body["msgKey"] != "sampleActionCard" {
t.Fatalf("msgKey = %v, want sampleActionCard", body["msgKey"])
}
if body["openConversationId"] != "cid-abc" {
t.Fatalf("openConversationId = %v, want cid-abc", body["openConversationId"])
@@ -350,13 +351,13 @@ func TestSupportGroupSenderLongUnicodeTruncation(t *testing.T) {
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
// Create a notification with very long Question and Answer
longQ := strings.Repeat("你好", 300) // 600 runes, each 3 bytes → 1800 bytes
longA := strings.Repeat("世界", 600) // 1200 runes, each 3 bytes → 3600 bytes
// Create a notification with a very long current and historical question.
longQ := strings.Repeat("你好", 300)
longPrevious := strings.Repeat("世界", 600)
notif := newTestNotification()
notif.Question = longQ
notif.Answer = longA
notif.PreviousQuestions = []string{longPrevious}
if err := s.Send(context.Background(), notif); err != nil {
t.Fatalf("Send with long content: %v", err)
@@ -370,12 +371,12 @@ func TestSupportGroupSenderLongUnicodeTruncation(t *testing.T) {
}
text := mp["text"]
// The text field should contain the long question and answer, truncated
// Both current and historical questions are present and rune-truncated.
if !strings.Contains(text, "你好") {
t.Fatal("text missing question content")
t.Fatal("text missing current question content")
}
if !strings.Contains(text, "世界") {
t.Fatal("text missing answer content")
t.Fatal("text missing historical question content")
}
// The full body size must be <= 64KiB
@@ -505,72 +506,36 @@ func TestSupportGroupSenderMarkdownContent(t *testing.T) {
text := mp["text"]
title := mp["title"]
// Title should contain Case ID
if !strings.Contains(title, "case-001") {
t.Fatalf("title = %q, want containing case-001", title)
if title != "知识库未覆盖提醒" {
t.Fatalf("title = %q", title)
}
// Text should contain key fields
checks := []string{
"case-001", // Case ID
"张三", // SenderName
"dingtalk", // Channel
"群聊", // ConversationType (group → 群聊)
"怎么配置", // Question snippet
"钉钉开放平台", // Answer snippet
"3", // ReferenceCount
"0.95", // TopScore
}
for _, want := range checks {
for _, want := range []string{"张三", "2026-07-24 10:16:35", "怎么配置", "之前的问题一", "之前的问题二", "Chinese"} {
if !strings.Contains(text, want) {
t.Fatalf("markdown text missing %q: %s", want, text)
t.Fatalf("action-card text missing %q: %s", want, text)
}
}
// Should NOT call references "文档数"
if strings.Contains(text, "文档数") {
t.Fatalf("markdown text should not use '文档数': %s", text)
for _, forbidden := range []string{"case-001", "回答摘要", "相关结果", "Score", "置信度"} {
if strings.Contains(text, forbidden) {
t.Fatalf("action-card text contains forbidden %q: %s", forbidden, text)
}
}
if mp["singleTitle"] != "去回答" || mp["singleURL"] == "" {
t.Fatalf("missing FAQ action: %#v", mp)
}
}
func TestSupportGroupSenderDirectConversationType(t *testing.T) {
func TestSupportGroupSenderNoPreviousQuestions(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
notif := newTestNotification()
notif.ConversationType = core.ConversationDirect
notif.PreviousQuestions = nil
if err := s.Send(context.Background(), notif); err != nil {
t.Fatalf("Send: %v", err)
}
raw := gs.lastSendBody["msgParam"].(string)
var mp map[string]string
json.Unmarshal([]byte(raw), &mp)
if !strings.Contains(mp["text"], "单聊") {
t.Fatalf("direct conversation should show '单聊': %s", mp["text"])
}
}
func TestSupportGroupSenderEmptyQuestionAnswer(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
notif := newTestNotification()
notif.Question = ""
notif.Answer = ""
if err := s.Send(context.Background(), notif); err != nil {
t.Fatalf("Send: %v", err)
}
// Should still succeed, just show empty fields
raw := gs.lastSendBody["msgParam"].(string)
if !strings.Contains(raw, "case-001") {
t.Fatal("markdown missing case ID")
if raw := gs.lastSendBody["msgParam"].(string); !strings.Contains(raw, "暂无历史问题") {
t.Fatalf("missing empty-history copy: %s", raw)
}
}
@@ -600,45 +565,3 @@ func TestSupportGroupSenderRejectsMalformedOrIncompleteSuccessResponse(t *testin
})
}
}
func TestSupportGroupSenderPreservesLowScorePrecision(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
notif := newTestNotification()
notif.TopScore = 0.0149
if err := s.Send(context.Background(), notif); err != nil {
t.Fatalf("Send: %v", err)
}
raw := gs.lastSendBody["msgParam"].(string)
if !strings.Contains(raw, "0.0149") {
t.Fatalf("low score precision lost: %s", raw)
}
}
func TestSupportGroupSenderZeroReferences(t *testing.T) {
gs := newGroupTestServer()
defer gs.Close()
s := newTestGroupSender(t, "appKey", "appSecret", "cid-abc", gs.server.Client(), gs.URL()+"/token", gs.URL()+"/groupMessages/send")
notif := newTestNotification()
notif.ReferenceCount = 0
notif.TopScore = 0
if err := s.Send(context.Background(), notif); err != nil {
t.Fatalf("Send: %v", err)
}
raw := gs.lastSendBody["msgParam"].(string)
var mp map[string]string
json.Unmarshal([]byte(raw), &mp)
if !strings.Contains(mp["text"], "0") {
t.Fatal("markdown missing ReferenceCount=0")
}
if !strings.Contains(mp["text"], "0.00") {
t.Fatalf("markdown missing TopScore=0.00: %s", mp["text"])
}
}
+8 -8
View File
@@ -99,14 +99,14 @@ func (b *blockingNotifier) Send(ctx context.Context, notif SupportNotification)
func makePendingCase(id string, attempts int) PendingSupportCase {
return PendingSupportCase{
SupportNotification: SupportNotification{
CaseID: id,
Question: "test question " + id,
Answer: "test answer " + id,
SenderName: "Sender " + id,
Channel: "dingtalk",
ConversationType: core.ConversationGroup,
ReferenceCount: 1,
TopScore: 0.5,
CaseID: id,
Question: "test question " + id,
SenderName: "Sender " + id,
Channel: "dingtalk",
ConversationType: core.ConversationGroup,
AskedAt: time.Unix(1, 0),
PreviousQuestions: []string{"previous question"},
FAQRecordID: "record-" + id,
},
Attempts: attempts,
}
+8 -8
View File
@@ -14,14 +14,14 @@ import (
// notification. It must never contain tokens, webhooks, raw API responses,
// raw error text, reference bodies, or group IDs.
type SupportNotification struct {
CaseID string
Question string
Answer string
SenderName string
Channel string
ConversationType core.ConversationType
ReferenceCount int
TopScore float64
CaseID string
Question string
SenderName string
Channel string
ConversationType core.ConversationType
AskedAt time.Time
PreviousQuestions []string
FAQRecordID string
}
// PendingSupportCase holds the data needed to send a notification for a
+31 -41
View File
@@ -2,68 +2,58 @@
package coverage
import (
"reflect"
"testing"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
)
func TestSupportNotificationFields(t *testing.T) {
// Verify that SupportNotification is a plain data struct with only the
// safe fields listed in the spec. Token, webhook, raw IDs, raw responses,
// and reference bodies must NOT be present.
func TestSupportNotificationCarriesCustomerQuestionContext(t *testing.T) {
askedAt := time.Date(2026, 7, 24, 10, 16, 35, 0, time.FixedZone("CST", 8*60*60))
notif := SupportNotification{
CaseID: "case-001",
Question: "怎么配置钉钉机器人?",
Answer: "进入钉钉开放平台...",
Question: "Nova5I具体支持哪些通信协议?",
SenderName: "张三",
Channel: "dingtalk",
ConversationType: core.ConversationGroup,
ReferenceCount: 3,
TopScore: 0.95,
AskedAt: askedAt,
PreviousQuestions: []string{
"Nova5I如何配置Modbus TCP?",
"Nova5I是否支持EtherCAT?",
},
FAQRecordID: "record-001",
}
if notif.CaseID != "case-001" {
t.Fatalf("CaseID = %q, want case-001", notif.CaseID)
if notif.Question != "Nova5I具体支持哪些通信协议?" || notif.SenderName != "张三" {
t.Fatalf("current question context mismatch: %+v", notif)
}
if notif.Question != "怎么配置钉钉机器人?" {
t.Fatalf("Question = %q", notif.Question)
if !notif.AskedAt.Equal(askedAt) {
t.Fatalf("AskedAt = %v, want %v", notif.AskedAt, askedAt)
}
if notif.Answer != "进入钉钉开放平台..." {
t.Fatalf("Answer = %q", notif.Answer)
if len(notif.PreviousQuestions) != 2 || notif.PreviousQuestions[0] != "Nova5I如何配置Modbus TCP?" {
t.Fatalf("PreviousQuestions = %#v", notif.PreviousQuestions)
}
if notif.SenderName != "张三" {
t.Fatalf("SenderName = %q", notif.SenderName)
if notif.FAQRecordID != "record-001" {
t.Fatalf("FAQRecordID = %q", notif.FAQRecordID)
}
if notif.Channel != "dingtalk" {
t.Fatalf("Channel = %q", notif.Channel)
}
if notif.ConversationType != core.ConversationGroup {
t.Fatalf("ConversationType = %q", notif.ConversationType)
}
if notif.ReferenceCount != 3 {
t.Fatalf("ReferenceCount = %d", notif.ReferenceCount)
}
if notif.TopScore != 0.95 {
t.Fatalf("TopScore = %f", notif.TopScore)
}
func TestSupportNotificationExcludesAnswerAndRetrievalDiagnostics(t *testing.T) {
typ := reflect.TypeOf(SupportNotification{})
for _, forbidden := range []string{"Answer", "ReferenceCount", "TopScore", "References", "Confidence"} {
if _, ok := typ.FieldByName(forbidden); ok {
t.Errorf("SupportNotification must not expose %s", forbidden)
}
}
}
func TestSupportNotificationEmptyValues(t *testing.T) {
// Zero-value notification should be safe to render (no panic).
notif := SupportNotification{}
if notif.CaseID != "" {
t.Fatalf("expected empty CaseID")
if notif.CaseID != "" || notif.Question != "" || notif.FAQRecordID != "" {
t.Fatalf("expected safe zero value: %+v", notif)
}
if notif.Question != "" {
t.Fatalf("expected empty Question")
}
if notif.Answer != "" {
t.Fatalf("expected empty Answer")
}
if notif.ReferenceCount != 0 {
t.Fatalf("expected zero ReferenceCount")
}
if notif.TopScore != 0 {
t.Fatalf("expected zero TopScore")
if len(notif.PreviousQuestions) != 0 || !notif.AskedAt.IsZero() {
t.Fatalf("expected empty history and time: %+v", notif)
}
}
+119
View File
@@ -0,0 +1,119 @@
package faqtable
import (
"context"
"errors"
"reflect"
"strings"
"time"
)
const FailureCodeRemoteInsertFailed = "remote_faq_table_insert_failed"
type PendingExport struct {
CaseID string
Row Row
Attempts int
}
type ExportRepository interface {
Claim(context.Context, int, int, time.Time) ([]PendingExport, error)
MarkExported(context.Context, string, string) error
MarkFailed(context.Context, string, string) error
}
type FieldsInserter interface {
InsertFields(context.Context, map[string]interface{}) (string, error)
}
type DispatcherOptions struct {
Batch int
MaxAttempts int
ClaimLease time.Duration
SendTimeout time.Duration
}
type DispatcherStats struct {
Claimed int
Exported int
Failed int
}
type Dispatcher struct {
repo ExportRepository
inserter FieldsInserter
batch int
maxAttempts int
claimLease time.Duration
sendTimeout time.Duration
}
func NewDispatcher(repo ExportRepository, inserter FieldsInserter, opts *DispatcherOptions) (*Dispatcher, error) {
if isNil(repo) || isNil(inserter) {
return nil, errors.New("faqtable dispatcher dependencies are required")
}
d := &Dispatcher{repo: repo, inserter: inserter, batch: 10, maxAttempts: 5, claimLease: 5 * time.Minute, sendTimeout: 10 * time.Second}
if opts != nil {
if opts.Batch < 0 || opts.MaxAttempts < 0 || opts.ClaimLease < 0 || opts.SendTimeout < 0 {
return nil, errors.New("faqtable dispatcher options are invalid")
}
if opts.Batch > 0 {
d.batch = opts.Batch
}
if opts.MaxAttempts > 0 {
d.maxAttempts = opts.MaxAttempts
}
if opts.ClaimLease > 0 {
d.claimLease = opts.ClaimLease
}
if opts.SendTimeout > 0 {
d.sendTimeout = opts.SendTimeout
}
}
return d, nil
}
func (d *Dispatcher) RunOnce(ctx context.Context) (DispatcherStats, error) {
var stats DispatcherStats
if err := ctx.Err(); err != nil {
return stats, err
}
items, err := d.repo.Claim(ctx, d.batch, d.maxAttempts, time.Now().Add(-d.claimLease))
if err != nil {
return stats, errors.New("faqtable dispatcher claim failed")
}
stats.Claimed = len(items)
for _, item := range items {
if err := ctx.Err(); err != nil {
return stats, err
}
itemCtx, cancel := context.WithTimeout(ctx, d.sendTimeout)
recordID, insertErr := d.inserter.InsertFields(itemCtx, item.Row.ToFields())
cancel()
if insertErr != nil || strings.TrimSpace(recordID) == "" {
if err := d.repo.MarkFailed(ctx, item.CaseID, FailureCodeRemoteInsertFailed); err != nil {
return stats, errors.New("faqtable dispatcher mark failed failed")
}
stats.Failed++
continue
}
if err := d.repo.MarkExported(ctx, item.CaseID, recordID); err != nil {
return stats, errors.New("faqtable dispatcher mark exported failed")
}
stats.Exported++
}
return stats, nil
}
func isNil(value interface{}) bool {
if value == nil {
return true
}
v := reflect.ValueOf(value)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return v.IsNil()
default:
return false
}
}
+83
View File
@@ -0,0 +1,83 @@
package faqtable
import (
"context"
"errors"
"testing"
"time"
)
type dispatcherRepoStub struct {
items []PendingExport
exported map[string]string
failed map[string]string
}
func (s *dispatcherRepoStub) Claim(context.Context, int, int, time.Time) ([]PendingExport, error) {
items := s.items
s.items = nil
return items, nil
}
func (s *dispatcherRepoStub) MarkExported(_ context.Context, id, recordID string) error {
if s.exported == nil {
s.exported = map[string]string{}
}
s.exported[id] = recordID
return nil
}
func (s *dispatcherRepoStub) MarkFailed(_ context.Context, id, code string) error {
if s.failed == nil {
s.failed = map[string]string{}
}
s.failed[id] = code
return nil
}
type fieldsInserterStub struct {
fields map[string]interface{}
id string
err error
}
func (s *fieldsInserterStub) InsertFields(_ context.Context, fields map[string]interface{}) (string, error) {
s.fields = fields
return s.id, s.err
}
func TestDispatcherExportsFAQAndPersistsRecordID(t *testing.T) {
repo := &dispatcherRepoStub{items: []PendingExport{{
CaseID: "case-1",
Row: Row{StandardQuestion: "中文问题", OriginalQuestion: "中文问题", AskerName: "张三", AskerUnionID: "union-1"},
}}}
inserter := &fieldsInserterStub{id: "record-1"}
d, err := NewDispatcher(repo, inserter, nil)
if err != nil {
t.Fatal(err)
}
stats, err := d.RunOnce(context.Background())
if err != nil {
t.Fatal(err)
}
if stats.Claimed != 1 || stats.Exported != 1 || stats.Failed != 0 {
t.Fatalf("stats=%+v", stats)
}
if repo.exported["case-1"] != "record-1" || inserter.fields[FieldLanguage] != "Chinese" {
t.Fatalf("exported=%#v fields=%#v", repo.exported, inserter.fields)
}
}
func TestDispatcherPersistsOnlyStableFAQFailureCode(t *testing.T) {
repo := &dispatcherRepoStub{items: []PendingExport{{CaseID: "case-2", Row: Row{StandardQuestion: "secret question", OriginalQuestion: "secret question"}}}}
inserter := &fieldsInserterStub{err: errors.New("token=secret raw question=secret question")}
d, err := NewDispatcher(repo, inserter, &DispatcherOptions{SendTimeout: time.Second})
if err != nil {
t.Fatal(err)
}
stats, err := d.RunOnce(context.Background())
if err != nil {
t.Fatal(err)
}
if stats.Failed != 1 || repo.failed["case-2"] != FailureCodeRemoteInsertFailed {
t.Fatalf("stats=%+v failed=%#v", stats, repo.failed)
}
}
+116
View File
@@ -0,0 +1,116 @@
package faqtable
import (
"errors"
"net/url"
"strings"
"unicode"
)
// Verified DingTalk Notable FAQ field IDs.
const (
FieldStandardQuestion = "01ZM8y7" // text: 标准问题(必填)
FieldOriginalQuestion = "fj3JQ1C" // text: 原始问题
FieldLanguage = "07YYagS" // text: 语言
FieldAsker = "Eu7gkKu" // user: 提问人
)
type Row struct {
StandardQuestion string
OriginalQuestion string
AskerName string
AskerUnionID string
}
func (r Row) ToFields() map[string]interface{} {
fields := map[string]interface{}{
FieldStandardQuestion: safeText(r.StandardQuestion),
FieldOriginalQuestion: safeText(r.OriginalQuestion),
FieldLanguage: DetectLanguage(r.OriginalQuestion),
}
if unionID := strings.TrimSpace(r.AskerUnionID); unionID != "" {
fields[FieldAsker] = []interface{}{map[string]interface{}{
"name": strings.TrimSpace(r.AskerName),
"unionId": unionID,
}}
}
return fields
}
func safeText(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if strings.ContainsRune("=+-@", rune(value[0])) {
return "'" + value
}
return value
}
func DetectLanguage(value string) string {
if strings.Contains(value, "日本語") {
return "Japanese"
}
var hasHan, hasKana, hasHangul, hasLatin, hasSpanish bool
for _, r := range value {
switch {
case unicode.In(r, unicode.Hiragana, unicode.Katakana):
hasKana = true
case unicode.In(r, unicode.Hangul):
hasHangul = true
case unicode.In(r, unicode.Han):
hasHan = true
case strings.ContainsRune("áéíóúüñÁÉÍÓÚÜÑ¿¡", r):
hasSpanish = true
case unicode.In(r, unicode.Latin):
hasLatin = true
}
}
switch {
case hasKana:
return "Japanese"
case hasHangul:
return "Korean"
case hasHan:
return "Chinese"
case hasSpanish:
return "Spanish"
case hasLatin:
return "English"
default:
return "Other"
}
}
type RecordLinkBuilder struct {
baseID string
sheetID string
viewID string
}
func NewRecordLinkBuilder(baseID, sheetID, viewID string) (*RecordLinkBuilder, error) {
baseID = strings.TrimSpace(baseID)
sheetID = strings.TrimSpace(sheetID)
viewID = strings.TrimSpace(viewID)
if baseID == "" || sheetID == "" || viewID == "" {
return nil, errors.New("faq record link configuration is incomplete")
}
return &RecordLinkBuilder{baseID: baseID, sheetID: sheetID, viewID: viewID}, nil
}
func (b *RecordLinkBuilder) Build(recordID string) (string, error) {
if b == nil {
return "", errors.New("faq record link builder is unavailable")
}
recordID = strings.TrimSpace(recordID)
if recordID == "" {
return "", errors.New("faq record id is required")
}
q := url.Values{}
q.Set("dentryUuid", b.baseID)
q.Set("rowId", recordID)
q.Set("sheetId", b.sheetID)
q.Set("viewId", b.viewID)
return (&url.URL{Scheme: "https", Host: "docs.dingtalk.com", Path: "/notable/record", RawQuery: q.Encode()}).String(), nil
}
+104
View File
@@ -0,0 +1,104 @@
package faqtable
import (
"net/url"
"strings"
"testing"
)
func TestRowToFieldsUsesVerifiedFAQContract(t *testing.T) {
fields := (Row{
StandardQuestion: "Nova5I支持哪些通信协议?",
OriginalQuestion: "Nova5I支持哪些通信协议?",
AskerName: "测试用户",
AskerUnionID: "union-1",
}).ToFields()
if got := fields[FieldStandardQuestion]; got != "Nova5I支持哪些通信协议?" {
t.Fatalf("standard question = %v", got)
}
if got := fields[FieldOriginalQuestion]; got != "Nova5I支持哪些通信协议?" {
t.Fatalf("original question = %v", got)
}
if got := fields[FieldLanguage]; got != "Chinese" {
t.Fatalf("language = %v, want Chinese", got)
}
users, ok := fields[FieldAsker].([]interface{})
if !ok || len(users) != 1 {
t.Fatalf("asker shape = %#v", fields[FieldAsker])
}
user, ok := users[0].(map[string]interface{})
if !ok || user["name"] != "测试用户" || user["unionId"] != "union-1" {
t.Fatalf("asker = %#v", users[0])
}
if len(fields) != 4 {
t.Fatalf("field count = %d, want exactly four", len(fields))
}
}
func TestRowToFieldsOmitsAskerWithoutUnionID(t *testing.T) {
fields := (Row{StandardQuestion: "Question", OriginalQuestion: "Question", AskerName: "name"}).ToFields()
if _, ok := fields[FieldAsker]; ok {
t.Fatal("asker must be omitted without unionId")
}
}
func TestDetectLanguageUsesFAQEnglishEnums(t *testing.T) {
cases := map[string]string{
"中文问题": "Chinese",
"日本語の質問": "Japanese",
"한국어 질문": "Korean",
"English question": "English",
"12345": "Other",
"日本語和漢字 mixed question": "Japanese",
}
for input, want := range cases {
if got := DetectLanguage(input); got != want {
t.Errorf("DetectLanguage(%q)=%q, want %q", input, got, want)
}
}
}
func TestRowToFieldsProtectsFormulaInjection(t *testing.T) {
fields := (Row{StandardQuestion: "=cmd", OriginalQuestion: "+cmd"}).ToFields()
if fields[FieldStandardQuestion] != "'=cmd" || fields[FieldOriginalQuestion] != "'+cmd" {
t.Fatalf("fields not sanitised: %#v", fields)
}
}
func TestRecordLinkBuilderUsesReturnedRecordIDAsRowID(t *testing.T) {
builder, err := NewRecordLinkBuilder("base+id", "sheet/id", "view id")
if err != nil {
t.Fatal(err)
}
got, err := builder.Build("record&id")
if err != nil {
t.Fatal(err)
}
parsed, err := url.Parse(got)
if err != nil {
t.Fatal(err)
}
if parsed.Scheme != "https" || parsed.Host != "docs.dingtalk.com" || parsed.Path != "/notable/record" {
t.Fatalf("unexpected link: %s", got)
}
q := parsed.Query()
if q.Get("dentryUuid") != "base+id" || q.Get("rowId") != "record&id" || q.Get("sheetId") != "sheet/id" || q.Get("viewId") != "view id" {
t.Fatalf("unexpected query: %#v", q)
}
}
func TestRecordLinkBuilderRejectsMissingConfigOrRecordID(t *testing.T) {
for _, args := range [][3]string{{"", "s", "v"}, {"b", "", "v"}, {"b", "s", ""}} {
if _, err := NewRecordLinkBuilder(args[0], args[1], args[2]); err == nil {
t.Fatalf("expected error for %#v", args)
}
}
builder, err := NewRecordLinkBuilder("b", "s", "v")
if err != nil {
t.Fatal(err)
}
if _, err := builder.Build(" "); err == nil || strings.Contains(err.Error(), "b") {
t.Fatalf("expected sanitised missing record error, got %v", err)
}
}
+8 -3
View File
@@ -152,9 +152,15 @@ type tokenResp struct {
ExpireIn int64 `json:"expireIn"`
}
// InsertRecord sends a single row to the DingTalk Notable v1 base.
// It returns the record ID on success.
// InsertRecord sends a single question-table row to DingTalk Notable v1.
// It remains the compatibility entry point for the existing question-table dispatcher.
func (c *Client) InsertRecord(ctx context.Context, row Row) (string, error) {
return c.InsertFields(ctx, row.ToFields())
}
// InsertFields sends one already-typed field map to DingTalk Notable v1 and
// returns the record ID. It is shared by other reviewed Notable table schemas.
func (c *Client) InsertFields(ctx context.Context, fields map[string]interface{}) (string, error) {
token, err := c.getAccessToken(ctx)
if err != nil {
return "", fmt.Errorf("questiontable token: %w", err)
@@ -164,7 +170,6 @@ func (c *Client) InsertRecord(ctx context.Context, row Row) (string, error) {
url.PathEscape(c.baseID), url.PathEscape(c.sheetID))
reqURL := c.baseURL + path + "?operatorId=" + url.QueryEscape(c.operatorID)
fields := row.ToFields()
body := insertRecordReq{Records: []insertRecord{{Fields: fields}}}
jsonBody, err := json.Marshal(body)
if err != nil {
@@ -0,0 +1,52 @@
package questiontable
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
)
func TestInsertFieldsUsesExactProvidedFAQFieldMap(t *testing.T) {
want := map[string]interface{}{
"01ZM8y7": "标准问题",
"fj3JQ1C": "原始问题",
"07YYagS": "Chinese",
}
var got map[string]interface{}
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"accessToken":"token","expireIn":7200}`))
})
mux.HandleFunc("/v1.0/notable/bases/faq-base/sheets/faq-sheet/records", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Records []struct {
Fields map[string]interface{} `json:"fields"`
} `json:"records"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Records) != 1 {
t.Fatalf("invalid request body: err=%v body=%#v", err, body)
}
got = body.Records[0].Fields
_, _ = w.Write([]byte(`{"value":[{"id":"faq-record-1"}]}`))
})
server := httptest.NewServer(mux)
defer server.Close()
client, err := NewClient(Config{
ClientID: "client", Secret: "secret", BaseID: "faq-base", SheetID: "faq-sheet", OperatorID: "operator",
TokenURL: server.URL + "/token", BaseURL: server.URL,
}, server.Client())
if err != nil {
t.Fatal(err)
}
recordID, err := client.InsertFields(context.Background(), want)
if err != nil {
t.Fatal(err)
}
if recordID != "faq-record-1" || !reflect.DeepEqual(got, want) {
t.Fatalf("recordID=%q fields=%#v", recordID, got)
}
}
+143
View File
@@ -0,0 +1,143 @@
package postgres
import (
"context"
"errors"
"fmt"
"strings"
"time"
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/faqtable"
)
var (
errFAQExportNilStore = errors.New("faq export store is unavailable")
errFAQCaseIDRequired = errors.New("faq export case id is required")
errFAQRecordIDRequired = errors.New("faq export record id is required")
errFAQRowsAffected = errors.New("faq export RowsAffected != 1")
errFAQFailureCodeInvalid = errors.New("faq export only remote_faq_table_insert_failed is accepted")
)
type FAQExportRepository struct{ store *Store }
var _ faqtable.ExportRepository = (*FAQExportRepository)(nil)
func NewFAQExportRepository(store *Store) (*FAQExportRepository, error) {
if store == nil || store.pool == nil {
return nil, errFAQExportNilStore
}
return &FAQExportRepository{store: store}, nil
}
func (r *FAQExportRepository) Claim(ctx context.Context, limit, maxAttempts int, staleBefore time.Time) ([]faqtable.PendingExport, error) {
if r == nil || r.store == nil || r.store.pool == nil {
return nil, errFAQExportNilStore
}
rows, err := r.store.pool.Query(ctx, `
WITH candidates AS (
SELECT sc.id
FROM support_cases sc
WHERE (sc.faq_export_status = 'pending'
OR (sc.faq_export_status = 'export_failed' AND (sc.faq_next_attempt_at IS NULL OR sc.faq_next_attempt_at <= NOW()))
OR (sc.faq_export_status = 'exporting' AND sc.faq_last_attempt_at < $3))
AND sc.faq_export_attempts < $2
ORDER BY sc.created_at, sc.id
LIMIT $1
FOR UPDATE SKIP LOCKED
),
claimed AS (
UPDATE support_cases sc
SET faq_export_status = 'exporting',
faq_export_attempts = sc.faq_export_attempts + 1,
faq_last_attempt_at = NOW(),
faq_next_attempt_at = NULL,
faq_failure_code = '',
updated_at = NOW()
FROM candidates c
WHERE sc.id = c.id
RETURNING sc.id, sc.faq_export_attempts, sc.tenant_key, sc.channel, sc.turn_id, sc.created_at
)
SELECT cl.id, cl.faq_export_attempts, t.question,
COALESCE(m.actor_name_snapshot, ''), COALESCE(m.actor_union_id_snapshot, '')
FROM claimed cl
JOIN turns t ON t.id = cl.turn_id
AND t.tenant_key = cl.tenant_key AND t.channel = cl.channel
JOIN messages m ON m.id = t.request_message_id
AND m.tenant_key = cl.tenant_key AND m.channel = cl.channel
ORDER BY cl.created_at, cl.id`, limit, maxAttempts, staleBefore)
if err != nil {
return nil, fmt.Errorf("faq export claim: %w", err)
}
defer rows.Close()
var exports []faqtable.PendingExport
for rows.Next() {
var id, question, askerName, askerUnionID string
var attempts int
if err := rows.Scan(&id, &attempts, &question, &askerName, &askerUnionID); err != nil {
return nil, fmt.Errorf("faq export claim scan: %w", err)
}
exports = append(exports, faqtable.PendingExport{
CaseID: id, Attempts: attempts,
Row: faqtable.Row{StandardQuestion: question, OriginalQuestion: question, AskerName: askerName, AskerUnionID: askerUnionID},
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("faq export claim rows: %w", err)
}
return exports, nil
}
func (r *FAQExportRepository) MarkExported(ctx context.Context, caseID, recordID string) error {
if strings.TrimSpace(caseID) == "" {
return errFAQCaseIDRequired
}
if strings.TrimSpace(recordID) == "" {
return errFAQRecordIDRequired
}
if r == nil || r.store == nil || r.store.pool == nil {
return errFAQExportNilStore
}
tag, err := r.store.pool.Exec(ctx, `
UPDATE support_cases
SET faq_export_status = 'exported', faq_record_id = $2,
faq_exported_at = NOW(), faq_failure_code = '', faq_next_attempt_at = NULL,
updated_at = NOW()
WHERE id = $1 AND faq_export_status = 'exporting'`, caseID, recordID)
if err != nil {
return fmt.Errorf("faq export mark exported: %w", err)
}
if tag.RowsAffected() != 1 {
return errFAQRowsAffected
}
return nil
}
func (r *FAQExportRepository) MarkFailed(ctx context.Context, caseID, failureCode string) error {
if strings.TrimSpace(caseID) == "" {
return errFAQCaseIDRequired
}
if failureCode != faqtable.FailureCodeRemoteInsertFailed {
return errFAQFailureCodeInvalid
}
if r == nil || r.store == nil || r.store.pool == nil {
return errFAQExportNilStore
}
tag, err := r.store.pool.Exec(ctx, `
UPDATE support_cases
SET faq_export_status = 'export_failed', faq_failure_code = $2,
faq_next_attempt_at = NOW() + CASE
WHEN faq_export_attempts <= 1 THEN '5 seconds'::interval
WHEN faq_export_attempts = 2 THEN '15 seconds'::interval
WHEN faq_export_attempts = 3 THEN '45 seconds'::interval
ELSE '5 minutes'::interval END,
updated_at = NOW()
WHERE id = $1 AND faq_export_status = 'exporting'`, caseID, failureCode)
if err != nil {
return fmt.Errorf("faq export mark failed: %w", err)
}
if tag.RowsAffected() != 1 {
return errFAQRowsAffected
}
return nil
}
@@ -0,0 +1,48 @@
package postgres
import (
"os"
"strings"
"testing"
)
func readFAQExportsSource(t *testing.T) string {
t.Helper()
data, err := os.ReadFile("faq_exports.go")
if err != nil {
t.Fatalf("read faq_exports.go: %v", err)
}
return strings.ToUpper(string(data))
}
func TestFAQExportsClaimIsAtomicScopedAndBounded(t *testing.T) {
src := readFAQExportsSource(t)
for _, fragment := range []string{
"WITH CANDIDATES", "FOR UPDATE SKIP LOCKED", "UPDATE SUPPORT_CASES",
"FAQ_EXPORT_STATUS = 'EXPORTING'", "FAQ_EXPORT_ATTEMPTS",
"T.TENANT_KEY = CL.TENANT_KEY", "T.CHANNEL = CL.CHANNEL",
"M.TENANT_KEY = CL.TENANT_KEY", "M.CHANNEL = CL.CHANNEL",
"T.QUESTION", "M.ACTOR_NAME_SNAPSHOT", "M.ACTOR_UNION_ID_SNAPSHOT",
} {
if !strings.Contains(src, fragment) {
t.Errorf("claim contract missing %q", fragment)
}
}
if strings.Contains(src, "T.ANSWER") {
t.Fatal("FAQ export must not read the generated answer")
}
}
func TestFAQExportsSuccessAndFailureTransitionsAreGuarded(t *testing.T) {
src := readFAQExportsSource(t)
for _, fragment := range []string{
"FAQ_EXPORT_STATUS = 'EXPORTED'", "FAQ_RECORD_ID = $2", "FAQ_EXPORTED_AT = NOW()",
"FAQ_EXPORT_STATUS = 'EXPORT_FAILED'", "FAQ_FAILURE_CODE = $2", "FAQ_NEXT_ATTEMPT_AT",
"WHERE ID = $1 AND FAQ_EXPORT_STATUS = 'EXPORTING'",
"REMOTE_FAQ_TABLE_INSERT_FAILED",
} {
if !strings.Contains(src, fragment) {
t.Errorf("transition contract missing %q", fragment)
}
}
}
@@ -33,6 +33,7 @@ func (s *Store) Claim(ctx context.Context, limit int, maxAttempts int, staleBefo
FROM support_cases sc
WHERE (sc.status = 'pending' OR sc.status = 'notification_failed'
OR (sc.status = 'notifying' AND sc.last_notification_at < $3))
AND sc.faq_export_status = 'exported'
AND sc.notification_attempts < $2
ORDER BY sc.created_at, sc.id
LIMIT $1
@@ -47,8 +48,8 @@ func (s *Store) Claim(ctx context.Context, limit int, maxAttempts int, staleBefo
updated_at = NOW()
FROM candidates c
WHERE sc.id = c.id
RETURNING sc.id, sc.tenant_key, sc.channel, sc.turn_id, sc.coverage_decision_id,
sc.notification_attempts, sc.created_at
RETURNING sc.id, sc.tenant_key, sc.channel, sc.turn_id,
sc.notification_attempts, sc.created_at, sc.faq_record_id
)
SELECT
cl.id,
@@ -57,25 +58,37 @@ func (s *Store) Claim(ctx context.Context, limit int, maxAttempts int, staleBefo
cl.turn_id,
cl.notification_attempts,
COALESCE(t.question, '') AS question,
COALESCE(t.answer, '') AS answer,
COALESCE(m.actor_name_snapshot, '') AS sender_name,
COALESCE(c.conversation_type, 'direct') AS conversation_type,
COALESCE(cd.reference_count, 0) AS reference_count,
COALESCE(cd.top_score, 0) AS top_score
m.created_at AS asked_at,
COALESCE(history.previous_questions, ARRAY[]::TEXT[]) AS previous_questions,
cl.faq_record_id
FROM claimed cl
JOIN turns t ON t.id = cl.turn_id
AND t.tenant_key = cl.tenant_key
AND t.channel = cl.channel
LEFT JOIN messages m ON m.id = t.request_message_id
JOIN messages m ON m.id = t.request_message_id
AND m.tenant_key = cl.tenant_key
AND m.channel = cl.channel
LEFT JOIN channel_conversations c ON c.id = m.conversation_id
AND c.tenant_key = cl.tenant_key
AND c.channel = cl.channel
JOIN coverage_decisions cd ON cd.id = cl.coverage_decision_id
AND cd.tenant_key = cl.tenant_key
AND cd.channel = cl.channel
AND cd.turn_id = cl.turn_id
LEFT JOIN LATERAL (
SELECT ARRAY_AGG(previous.question ORDER BY previous.created_at DESC) AS previous_questions
FROM (
SELECT t2.question, t2.created_at
FROM turns t2
JOIN messages m2 ON m2.id = t2.request_message_id
AND m2.tenant_key = t2.tenant_key
AND m2.channel = t2.channel
WHERE t2.tenant_key = cl.tenant_key
AND t2.channel = cl.channel
AND m2.actor_id = m.actor_id
AND t2.created_at < t.created_at
ORDER BY t2.created_at DESC
LIMIT 5
) previous
) history ON TRUE
ORDER BY cl.created_at, cl.id`,
limit, maxAttempts, staleBefore)
if err != nil {
@@ -86,34 +99,34 @@ func (s *Store) Claim(ctx context.Context, limit int, maxAttempts int, staleBefo
var cases []coverage.PendingSupportCase
for rows.Next() {
var (
id string
tenantKey string
channel string
turnID string
attempts int
question string
answer string
senderName string
conversationType string
referenceCount int
topScore float64
id string
tenantKey string
channel string
turnID string
attempts int
question string
senderName string
conversationType string
askedAt time.Time
previousQuestions []string
faqRecordID string
)
if err := rows.Scan(&id, &tenantKey, &channel, &turnID, &attempts,
&question, &answer, &senderName, &conversationType,
&referenceCount, &topScore); err != nil {
&question, &senderName, &conversationType, &askedAt,
&previousQuestions, &faqRecordID); err != nil {
rows.Close()
return nil, fmt.Errorf("claim scan: %w", err)
}
cases = append(cases, coverage.PendingSupportCase{
SupportNotification: coverage.SupportNotification{
CaseID: id,
Question: question,
Answer: answer,
SenderName: senderName,
Channel: channel,
ConversationType: core.ConversationType(conversationType),
ReferenceCount: referenceCount,
TopScore: topScore,
CaseID: id,
Question: question,
SenderName: senderName,
Channel: channel,
ConversationType: core.ConversationType(conversationType),
AskedAt: askedAt,
PreviousQuestions: previousQuestions,
FAQRecordID: faqRecordID,
},
Attempts: attempts,
})
@@ -136,38 +136,34 @@ func TestSupportNotificationsClaimScopeJoins(t *testing.T) {
}
}
func TestSupportNotificationsClaimUsesPersistedCoverageDecisionID(t *testing.T) {
func TestSupportNotificationsClaimWaitsForFAQExport(t *testing.T) {
joined := strings.ToUpper(strings.Join(extractSQLFromSupportNotifications(t), " "))
if !strings.Contains(joined, "SC.COVERAGE_DECISION_ID") {
t.Fatal("Claim must carry support_cases.coverage_decision_id through the claimed CTE")
if !strings.Contains(joined, "FAQ_EXPORT_STATUS = 'EXPORTED'") {
t.Fatal("Claim must wait until the FAQ record has been exported")
}
if !strings.Contains(joined, "CD.ID = CL.COVERAGE_DECISION_ID") {
t.Fatal("Claim must join the exact persisted coverage decision ID")
if !strings.Contains(joined, "FAQ_RECORD_ID") {
t.Fatal("Claim must return the FAQ record ID for the action button")
}
}
func TestSupportNotificationsClaimReadsQuestionAnswerFromTurns(t *testing.T) {
sqls := extractSQLFromSupportNotifications(t)
joined := strings.ToUpper(strings.Join(sqls, " "))
// Must read question/answer from turns table, not from support_cases
if !strings.Contains(joined, "T.QUESTION") {
t.Error("Claim must read question from turns table")
}
if !strings.Contains(joined, "T.ANSWER") {
t.Error("Claim must read answer from turns table")
func TestSupportNotificationsClaimReadsQuestionTimeAndFivePreviousQuestions(t *testing.T) {
joined := strings.ToUpper(strings.Join(extractSQLFromSupportNotifications(t), " "))
for _, required := range []string{
"T.QUESTION", "M.CREATED_AT", "M2.ACTOR_ID = M.ACTOR_ID",
"T2.CREATED_AT < T.CREATED_AT", "ORDER BY T2.CREATED_AT DESC", "LIMIT 5",
} {
if !strings.Contains(joined, required) {
t.Errorf("Claim missing customer-history contract fragment %q", required)
}
}
}
func TestSupportNotificationsClaimReadsReferenceCountAndTopScore(t *testing.T) {
sqls := extractSQLFromSupportNotifications(t)
joined := strings.ToUpper(strings.Join(sqls, " "))
if !strings.Contains(joined, "REFERENCE_COUNT") {
t.Error("Claim must read reference_count from coverage_decisions")
}
if !strings.Contains(joined, "TOP_SCORE") {
t.Error("Claim must read top_score from coverage_decisions")
func TestSupportNotificationsClaimDoesNotReadAnswerOrRetrievalDiagnostics(t *testing.T) {
joined := strings.ToUpper(strings.Join(extractSQLFromSupportNotifications(t), " "))
for _, forbidden := range []string{"T.ANSWER", "REFERENCE_COUNT", "TOP_SCORE"} {
if strings.Contains(joined, forbidden) {
t.Errorf("Claim must not read notification field %q", forbidden)
}
}
}