feat: add FAQ uncovered workflow
This commit is contained in:
+10
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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", "感谢反馈,我们会继续改进"},
|
||||
|
||||
@@ -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)), ¶ms); 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user