feat: add language support and tests for English and Chinese seed data
- Introduced a new package `seedlang` to handle language parsing and constants for English and Chinese. - Updated `Init` functions across various modules (kb, quickreply, skill, tag) to accept a language parameter. - Implemented English seed data for FAQs, quick replies, skills, and tags, ensuring no Chinese text is present in English data. - Added tests to verify that English seed data does not contain Chinese characters. - Removed outdated README file from the kb test data directory. - Created new test files for channel and kb to validate the absence of Chinese text in English seed data.
This commit is contained in:
Vendored
+58
-26
@@ -1,6 +1,7 @@
|
||||
package agentteam
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
@@ -25,7 +26,7 @@ type InitResult struct {
|
||||
// 2. 客服A 用户
|
||||
// 3. 客服B 用户
|
||||
// 4. 为客服A和客服B创建客服档案,关联到该客服组
|
||||
func Init() (*InitResult, error) {
|
||||
func Init(lang seedlang.Language) (*InitResult, error) {
|
||||
result := &InitResult{}
|
||||
|
||||
// 获取管理员用户
|
||||
@@ -39,7 +40,7 @@ func Init() (*InitResult, error) {
|
||||
}
|
||||
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
return initTeamAndUsers(ctx, adminUser, result)
|
||||
return initTeamAndUsers(ctx, adminUser, result, lang)
|
||||
})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("init team and users failed: %w", err)
|
||||
@@ -48,8 +49,8 @@ func Init() (*InitResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func initTeamAndUsers(ctx *sqls.TxContext, leaderUser *models.User, result *InitResult) error {
|
||||
teamName := "默认客服组"
|
||||
func initTeamAndUsers(ctx *sqls.TxContext, leaderUser *models.User, result *InitResult, lang seedlang.Language) error {
|
||||
teamName := localizedTeamName(lang)
|
||||
now := time.Now()
|
||||
|
||||
team := repositories.AgentTeamRepository.Take(ctx.Tx, "name = ?", teamName)
|
||||
@@ -83,28 +84,7 @@ func initTeamAndUsers(ctx *sqls.TxContext, leaderUser *models.User, result *Init
|
||||
result.TeamCreated = true
|
||||
}
|
||||
|
||||
agentUsers := []struct {
|
||||
username string
|
||||
nickname string
|
||||
code string
|
||||
}{
|
||||
{
|
||||
username: leaderUser.Username,
|
||||
nickname: "客服组长",
|
||||
code: "AGENT_LEADER_A",
|
||||
},
|
||||
{
|
||||
username: "agent_a",
|
||||
nickname: "客服A",
|
||||
code: "AGENT_A",
|
||||
},
|
||||
{
|
||||
username: "agent_b",
|
||||
nickname: "客服B",
|
||||
code: "AGENT_B",
|
||||
},
|
||||
}
|
||||
|
||||
agentUsers := localizedAgentUsers(lang, leaderUser.Username)
|
||||
for _, agentUser := range agentUsers {
|
||||
userID, userCreated, err := createOrGetUser(ctx, agentUser.username, agentUser.nickname)
|
||||
if err != nil {
|
||||
@@ -129,6 +109,58 @@ func initTeamAndUsers(ctx *sqls.TxContext, leaderUser *models.User, result *Init
|
||||
return nil
|
||||
}
|
||||
|
||||
type agentUserSeed struct {
|
||||
username string
|
||||
nickname string
|
||||
code string
|
||||
}
|
||||
|
||||
func localizedTeamName(lang seedlang.Language) string {
|
||||
if lang == seedlang.English {
|
||||
return "Default Support Team"
|
||||
}
|
||||
return "默认客服组"
|
||||
}
|
||||
|
||||
func localizedAgentUsers(lang seedlang.Language, leaderUsername string) []agentUserSeed {
|
||||
if lang == seedlang.English {
|
||||
return []agentUserSeed{
|
||||
{
|
||||
username: leaderUsername,
|
||||
nickname: "Support Lead",
|
||||
code: "AGENT_LEADER_A",
|
||||
},
|
||||
{
|
||||
username: "agent_a",
|
||||
nickname: "Agent A",
|
||||
code: "AGENT_A",
|
||||
},
|
||||
{
|
||||
username: "agent_b",
|
||||
nickname: "Agent B",
|
||||
code: "AGENT_B",
|
||||
},
|
||||
}
|
||||
}
|
||||
return []agentUserSeed{
|
||||
{
|
||||
username: leaderUsername,
|
||||
nickname: "客服组长",
|
||||
code: "AGENT_LEADER_A",
|
||||
},
|
||||
{
|
||||
username: "agent_a",
|
||||
nickname: "客服A",
|
||||
code: "AGENT_A",
|
||||
},
|
||||
{
|
||||
username: "agent_b",
|
||||
nickname: "客服B",
|
||||
code: "AGENT_B",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createOrGetUser(ctx *sqls.TxContext, username, nickname string) (int64, bool, error) {
|
||||
user := repositories.UserRepository.Take(
|
||||
ctx.Tx,
|
||||
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
package agentteam
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishAgentTeamTextDoesNotContainChineseText(t *testing.T) {
|
||||
values := []string{localizedTeamName(seedlang.English)}
|
||||
for _, user := range localizedAgentUsers(seedlang.English, "admin") {
|
||||
values = append(values, user.nickname)
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english agent team seed contains Chinese text: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+38
-3
@@ -1,6 +1,7 @@
|
||||
package aiagent
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/skill"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
@@ -19,7 +20,7 @@ type InitResult struct {
|
||||
|
||||
// Init 初始化 AI Agent 测试数据
|
||||
// 依赖于 AI Config 和 Knowledge Base 已初始化
|
||||
func Init() (*InitResult, error) {
|
||||
func Init(lang seedlang.Language) (*InitResult, error) {
|
||||
result := &InitResult{}
|
||||
|
||||
aiConfigID, err := getDefaultAIConfigID()
|
||||
@@ -41,7 +42,7 @@ func Init() (*InitResult, error) {
|
||||
return result, fmt.Errorf("get default skill ids failed: %w", err)
|
||||
}
|
||||
|
||||
seedItems := buildSeedItems(aiConfigID, knowledgeIDs, defaultTeamIDs, defaultSkillIDs)
|
||||
seedItems := buildSeedItems(lang, aiConfigID, knowledgeIDs, defaultTeamIDs, defaultSkillIDs)
|
||||
for _, item := range seedItems {
|
||||
itemCopy := item
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
@@ -68,8 +69,42 @@ func Init() (*InitResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildSeedItems(aiConfigID int64, knowledgeIDs []int64, defaultTeamIDs string, defaultSkillIDs string) []models.AIAgent {
|
||||
func buildSeedItems(lang seedlang.Language, aiConfigID int64, knowledgeIDs []int64, defaultTeamIDs string, defaultSkillIDs string) []models.AIAgent {
|
||||
now := time.Now()
|
||||
if lang == seedlang.English {
|
||||
return []models.AIAgent{
|
||||
{
|
||||
Name: "Test AI Support Agent",
|
||||
Description: "Local test AI support agent",
|
||||
Status: enums.StatusOk,
|
||||
AIConfigID: aiConfigID,
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
SystemPrompt: `You are working in a customer support system with explicit engineering constraints.
|
||||
During execution, strictly follow the injected Agent rules and skill rules.
|
||||
If tool allowlist restrictions exist, call only the currently allowed tools. Ask follow-up questions when information is insufficient; do not fabricate facts or skip required confirmations.
|
||||
Do not promise processing times, completion times, callbacks, or contact times unless they have been confirmed by system context, tool results, human confirmation, or knowledge base facts.
|
||||
Do not make commitments on behalf of the human team, technical team, or after-sales team unless the current context contains explicit tool results, human confirmation, or knowledge base facts.
|
||||
When the user only says that they have sent materials, an email, screenshots, or attachments, only acknowledge the current message or suggest waiting for human confirmation. Do not invent internal handling processes, SLAs, or follow-up arrangements.`,
|
||||
WelcomeMessage: "Hello, how can I help you?",
|
||||
ReplyTimeoutSeconds: 180,
|
||||
TeamIDs: defaultTeamIDs,
|
||||
HandoffMode: enums.AIAgentHandoffModeWaitPool,
|
||||
FallbackMode: enums.AIAgentFallbackModeSuggestRetry,
|
||||
FallbackMessage: "I could not find enough accurate information yet. Please add more details and I will keep checking.",
|
||||
KnowledgeIDs: utils.JoinInt64s(knowledgeIDs),
|
||||
SkillIDs: defaultSkillIDs,
|
||||
SortNo: 10,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 0,
|
||||
CreateUserName: "System",
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: "System",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return []models.AIAgent{
|
||||
{
|
||||
Name: "测试AI客服",
|
||||
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
package aiagent
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishAIAgentSeedDoesNotContainChineseText(t *testing.T) {
|
||||
for _, item := range buildSeedItems(seedlang.English, 1, []int64{2}, "3", "4") {
|
||||
values := []string{item.Name, item.Description, item.SystemPrompt, item.WelcomeMessage, item.FallbackMessage}
|
||||
for _, value := range values {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english AI agent seed contains Chinese text: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+31
-3
@@ -1,6 +1,7 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
@@ -20,7 +21,7 @@ type InitResult struct {
|
||||
|
||||
// Init 初始化 Channel 测试数据。
|
||||
// 依赖于 AI Agent 已初始化。
|
||||
func Init() (*InitResult, error) {
|
||||
func Init(lang seedlang.Language) (*InitResult, error) {
|
||||
result := &InitResult{}
|
||||
|
||||
aiAgentID, err := getDefaultAIAgentID()
|
||||
@@ -31,7 +32,7 @@ func Init() (*InitResult, error) {
|
||||
return result, fmt.Errorf("no default ai agent found, please init ai agent first")
|
||||
}
|
||||
|
||||
seedItems := buildSeedItems(aiAgentID)
|
||||
seedItems := buildSeedItems(lang, aiAgentID)
|
||||
for _, item := range seedItems {
|
||||
itemCopy := item
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
@@ -56,8 +57,35 @@ func Init() (*InitResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildSeedItems(aiAgentID int64) []models.Channel {
|
||||
func buildSeedItems(lang seedlang.Language, aiAgentID int64) []models.Channel {
|
||||
now := time.Now()
|
||||
if lang == seedlang.English {
|
||||
return []models.Channel{
|
||||
{
|
||||
Name: "Website Support",
|
||||
ChannelType: enums.ChannelTypeWeb,
|
||||
ChannelID: strs.UUID(),
|
||||
AIAgentID: aiAgentID,
|
||||
ConfigJSON: jsons.ToJsonStr(dto.WebChannelConfig{
|
||||
Title: "Online Support",
|
||||
Subtitle: "Powered by AgentDesk",
|
||||
ThemeColor: "#2563eb",
|
||||
Position: "right",
|
||||
Width: "780px",
|
||||
}),
|
||||
Status: enums.StatusOk,
|
||||
Remark: "Local testdata seed",
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 0,
|
||||
CreateUserName: "System",
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: "System",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return []models.Channel{
|
||||
{
|
||||
Name: "官网客服",
|
||||
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishChannelSeedDoesNotContainChineseText(t *testing.T) {
|
||||
for _, item := range buildSeedItems(seedlang.English, 1) {
|
||||
values := []string{item.Name, item.ConfigJSON, item.Remark}
|
||||
for _, value := range values {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english channel seed contains Chinese text: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
# 初始化 FAQ 知识库测试数据
|
||||
|
||||
`kb.Init()` 会初始化“贝壳客服平台 FAQ”知识库,用于本地 AI Agent 和知识检索调试。
|
||||
Vendored
+68
-11
@@ -1,6 +1,7 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/constants"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
@@ -26,13 +27,13 @@ type faqSeed struct {
|
||||
Remark string
|
||||
}
|
||||
|
||||
func Init() (*InitResult, error) {
|
||||
faqSeeds := knowledgeFAQSeeds()
|
||||
func Init(lang seedlang.Language) (*InitResult, error) {
|
||||
faqSeeds := knowledgeFAQSeeds(lang)
|
||||
result := &InitResult{
|
||||
TotalFAQs: len(faqSeeds),
|
||||
}
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
faqKnowledgeBase, ensureFAQErr := ensureFAQKnowledgeBase(ctx.Tx)
|
||||
faqKnowledgeBase, ensureFAQErr := ensureFAQKnowledgeBase(ctx.Tx, lang)
|
||||
if ensureFAQErr != nil {
|
||||
return ensureFAQErr
|
||||
}
|
||||
@@ -58,13 +59,14 @@ func Init() (*InitResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ensureFAQKnowledgeBase(db *gorm.DB) (*models.KnowledgeBase, error) {
|
||||
func ensureFAQKnowledgeBase(db *gorm.DB, lang seedlang.Language) (*models.KnowledgeBase, error) {
|
||||
now := time.Now()
|
||||
item := repositories.KnowledgeBaseRepository.FindOne(db, sqls.NewCnd().Eq("name", "贝壳客服平台 FAQ"))
|
||||
name, description, remark := faqKnowledgeBaseText(lang)
|
||||
item := repositories.KnowledgeBaseRepository.FindOne(db, sqls.NewCnd().Eq("name", name))
|
||||
if item == nil {
|
||||
item = &models.KnowledgeBase{
|
||||
Name: "贝壳客服平台 FAQ",
|
||||
Description: "模拟真实客服场景的 FAQ 测试数据,覆盖账号、坐席、机器人、知识库、工单、计费与发票等常见问题。",
|
||||
Name: name,
|
||||
Description: description,
|
||||
KnowledgeType: string(enums.KnowledgeBaseTypeFAQ),
|
||||
Status: enums.StatusOk,
|
||||
DefaultTopK: 8,
|
||||
@@ -75,7 +77,7 @@ func ensureFAQKnowledgeBase(db *gorm.DB) (*models.KnowledgeBase, error) {
|
||||
ChunkMaxTokens: 0,
|
||||
ChunkOverlapTokens: 0,
|
||||
AnswerMode: int(enums.KnowledgeAnswerModeStrict),
|
||||
Remark: "测试数据初始化自动生成",
|
||||
Remark: remark,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: constants.SystemAuditUserID,
|
||||
@@ -92,7 +94,7 @@ func ensureFAQKnowledgeBase(db *gorm.DB) (*models.KnowledgeBase, error) {
|
||||
}
|
||||
|
||||
err := repositories.KnowledgeBaseRepository.Updates(db, item.ID, map[string]any{
|
||||
"description": "模拟真实客服场景的 FAQ 测试数据,覆盖账号、坐席、机器人、知识库、工单、计费与发票等常见问题。",
|
||||
"description": description,
|
||||
"knowledge_type": string(enums.KnowledgeBaseTypeFAQ),
|
||||
"status": enums.StatusOk,
|
||||
"default_top_k": 8,
|
||||
@@ -103,7 +105,7 @@ func ensureFAQKnowledgeBase(db *gorm.DB) (*models.KnowledgeBase, error) {
|
||||
"chunk_max_tokens": 0,
|
||||
"chunk_overlap_tokens": 0,
|
||||
"answer_mode": int(enums.KnowledgeAnswerModeStrict),
|
||||
"remark": "测试数据初始化自动生成",
|
||||
"remark": remark,
|
||||
"update_user_id": constants.SystemAuditUserID,
|
||||
"update_user_name": constants.SystemAuditUserName,
|
||||
"updated_at": now,
|
||||
@@ -114,6 +116,17 @@ func ensureFAQKnowledgeBase(db *gorm.DB) (*models.KnowledgeBase, error) {
|
||||
return repositories.KnowledgeBaseRepository.Get(db, item.ID), nil
|
||||
}
|
||||
|
||||
func faqKnowledgeBaseText(lang seedlang.Language) (name string, description string, remark string) {
|
||||
if lang == seedlang.English {
|
||||
return "AgentDesk Support Platform FAQ",
|
||||
"FAQ test data that simulates real support scenarios, covering accounts, agents, AI bots, knowledge bases, tickets, billing, invoices, and troubleshooting.",
|
||||
"Generated by testdata initialization"
|
||||
}
|
||||
return "贝壳客服平台 FAQ",
|
||||
"模拟真实客服场景的 FAQ 测试数据,覆盖账号、坐席、机器人、知识库、工单、计费与发票等常见问题。",
|
||||
"测试数据初始化自动生成"
|
||||
}
|
||||
|
||||
func upsertKnowledgeFAQ(db *gorm.DB, knowledgeBaseID int64, seed faqSeed) (bool, error) {
|
||||
now := time.Now()
|
||||
similarQuestions, err := json.Marshal(seed.SimilarQuestions)
|
||||
@@ -165,7 +178,51 @@ func upsertKnowledgeFAQ(db *gorm.DB, knowledgeBaseID int64, seed faqSeed) (bool,
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func knowledgeFAQSeeds() []faqSeed {
|
||||
func knowledgeFAQSeeds(lang seedlang.Language) []faqSeed {
|
||||
if lang == seedlang.English {
|
||||
return englishKnowledgeFAQSeeds()
|
||||
}
|
||||
return chineseKnowledgeFAQSeeds()
|
||||
}
|
||||
|
||||
func englishKnowledgeFAQSeeds() []faqSeed {
|
||||
return []faqSeed{
|
||||
{Question: "What should I do if the admin console says my account or password is incorrect?", Answer: "First confirm that the account is complete and that the email domain is correct. Passwords are case-sensitive. After 5 failed attempts, the account may be locked for 15 minutes. If you still cannot sign in, ask an organization administrator to reset the password in Organization Settings > Members, or use Forgot Password on the sign-in page.", SimilarQuestions: []string{"admin password is wrong", "cannot sign in to the console", "username or password error"}, Remark: "Account login"},
|
||||
{Question: "How do I reset a forgotten password?", Answer: "Click Forgot Password on the sign-in page, enter the bound email address, and use the verification code to reset the password. If the account has no usable email address, contact an enterprise administrator for a reset. After a successful reset, the account must sign in again on all devices.", SimilarQuestions: []string{"forgot password", "reset console password", "recover login password"}, Remark: "Account login"},
|
||||
{Question: "Why am I not receiving the login verification email?", Answer: "Check spam, promotions, and your enterprise mail quarantine first. If no email arrives within 5 minutes, confirm the email address and ask your IT team to allowlist the platform sender domain. Repeated missing emails may indicate provider throttling, so try a backup email if available.", SimilarQuestions: []string{"verification email not received", "no login code email", "email code missing"}, Remark: "Account login"},
|
||||
{Question: "Can multiple people use the same agent account at the same time?", Answer: "Sharing one agent account is not recommended. The platform may allow the same account on multiple devices, but login logs and abnormal activity reminders are recorded. For traceability, permission isolation, and accurate conversation assignment, each agent should use a separate account.", SimilarQuestions: []string{"share one account", "multiple people one login", "same account on many computers"}, Remark: "Account login"},
|
||||
{Question: "How do I create a console account for a new team member?", Answer: "An administrator can open Organization Settings > Members, click Add Member, fill in name, email, team, and role, then save. The system sends an activation email and the member sets a password on first sign-in. If SSO is enabled, members can also be synced from the enterprise identity provider.", SimilarQuestions: []string{"create new agent account", "add employee account", "invite member to console"}, Remark: "Member management"},
|
||||
{Question: "What is the difference between online, busy, and offline agent statuses?", Answer: "Online agents can receive new conversations. Busy agents stop receiving new conversations but can continue existing ones. Offline agents do not join assignment or receive real-time reminders. If automatic status switching is enabled, long inactivity or sign-out can move the agent offline.", SimilarQuestions: []string{"agent status meaning", "online busy offline difference", "support agent status"}, Remark: "Agent service"},
|
||||
{Question: "How are conversations assigned to agents?", Answer: "By default, conversations are assigned by skill group and round-robin rules. The platform can also consider current workload, recent response time, and priority. If a customer matches channel, language, or tag conditions, routing can prefer the matching team or agent.", SimilarQuestions: []string{"conversation routing rule", "how chats are assigned", "new conversation distribution"}, Remark: "Agent service"},
|
||||
{Question: "Can a conversation be automatically closed when the customer does not reply?", Answer: "Yes. In Reception Settings > Conversation Rules, configure the no-response timeout, such as closing the conversation after 30 minutes without a customer reply. If the customer sends another message later, a new conversation is created and history remains visible in the customer timeline.", SimilarQuestions: []string{"auto close conversation", "customer no reply timeout", "inactive chat closing"}, Remark: "Agent service"},
|
||||
{Question: "When does the AI bot take over a conversation?", Answer: "When the channel uses AI-first service and the message matches the bot's service hours, language, and business scope, the bot replies first. If the customer asks for a human, matches a handoff rule, or the bot misses knowledge repeatedly, the system can switch to a human agent.", SimilarQuestions: []string{"AI bot service rules", "when does AI answer first", "bot to human conditions"}, Remark: "AI bot"},
|
||||
{Question: "What should I check first when the bot answer is inaccurate?", Answer: "First check whether the knowledge base covers the question, whether FAQ wording is too far from user wording, whether the recall threshold is too high, and whether the wrong knowledge base is enabled. If retrieval hits are good but answers still drift, review the prompt, answer mode, and reranking strategy.", SimilarQuestions: []string{"AI answer inaccurate", "bot answers wrong", "what config to check first"}, Remark: "AI bot"},
|
||||
{Question: "How do I restrict a bot to after-sales questions only?", Answer: "Bind the bot to an after-sales knowledge base and restrict routing to after-sales channels, entry points, or tagged conversations. Set the fallback policy to guide users to a human agent so the bot avoids answering out-of-scope questions.", SimilarQuestions: []string{"bot only after sales", "limit bot answer scope", "bind bot to business scope"}, Remark: "AI bot"},
|
||||
{Question: "Does the bot support multilingual replies?", Answer: "Yes, it can support English, Chinese, and other languages supported by the connected model, but the knowledge base should contain the corresponding language content. If only Chinese knowledge is uploaded, English business answers may not be stable. Use separate language-specific knowledge bases for better quality.", SimilarQuestions: []string{"AI supports English", "multilingual bot", "serve English customers"}, Remark: "AI bot"},
|
||||
{Question: "What is the difference between a document knowledge base and an FAQ knowledge base?", Answer: "A document knowledge base works well for long help articles, policies, and manuals because the system chunks and retrieves passages. An FAQ knowledge base works better for standard question-answer pairs and high-frequency support questions. If the content has fixed questions and fixed answers, use FAQ first.", SimilarQuestions: []string{"FAQ vs document knowledge base", "when to use FAQ", "knowledge base type selection"}, Remark: "Knowledge base"},
|
||||
{Question: "How long after uploading a document can the bot retrieve it?", Answer: "After upload, the document enters the indexing queue. Small text documents usually finish in 1 to 3 minutes. Longer files, image-heavy documents, or busy indexing queues can take longer. You can check indexing status in the knowledge base list.", SimilarQuestions: []string{"document indexing time", "knowledge upload effective time", "when can AI use the document"}, Remark: "Knowledge base"},
|
||||
{Question: "Can FAQ entries be imported in bulk?", Answer: "Yes. Download the template and fill in question, answer, similar questions, and remark columns, then import it from the FAQ page. Similar questions should be separated by line breaks or semicolons, and duplicate questions should be removed before import.", SimilarQuestions: []string{"bulk FAQ upload", "FAQ import template", "batch import Q&A"}, Remark: "Knowledge base"},
|
||||
{Question: "Why does an uploaded document show indexing failed?", Answer: "Common causes include empty documents, text copied as images, excessive content length, unavailable vector service, or model configuration errors. Check the indexing error first, then convert the file to plain text or Markdown and upload it again if needed.", SimilarQuestions: []string{"document indexing failed", "knowledge processing failed", "upload error after indexing"}, Remark: "Knowledge base"},
|
||||
{Question: "How do I embed the website support button on our site?", Answer: "Open Channel Access > Web Widget, copy the generated script, and paste it before the closing body tag on your website. If the site uses a Content Security Policy, add the platform domain to the allowed list. After publishing, test both desktop and mobile pages.", SimilarQuestions: []string{"embed web widget", "website chat button", "install support script"}, Remark: "Channel access"},
|
||||
{Question: "Can the web widget use our brand color and logo?", Answer: "Yes. In the web channel configuration, set the title, subtitle, theme color, position, width, and brand assets. After saving, refresh the website page. If CDN caching is enabled, it may take a few minutes for the change to appear.", SimilarQuestions: []string{"custom web widget brand", "change chat color", "support widget logo"}, Remark: "Channel access"},
|
||||
{Question: "How do I create a support ticket from a conversation?", Answer: "In the conversation detail page, click Create Ticket, select the issue category and priority, fill in the title and description, then submit. Conversation context can be linked to the ticket so the follow-up team can see the original messages.", SimilarQuestions: []string{"create ticket from chat", "turn conversation into ticket", "submit support ticket"}, Remark: "Tickets"},
|
||||
{Question: "What information should be included when reporting an urgent issue?", Answer: "Include the issue time, organization ID, affected channel or customer scope, screenshots, error messages, and reproduction steps. Clear impact and steps help the support team triage faster and avoid repeated clarification.", SimilarQuestions: []string{"urgent incident information", "report system failure", "what to include in a ticket"}, Remark: "Tickets"},
|
||||
{Question: "How do I configure automatic welcome messages?", Answer: "Enable the welcome message in the channel or bot configuration. You can use different text by working hours, channel, customer tag, or page URL. Keep the message concise and clarify whether the current service is AI or human.", SimilarQuestions: []string{"automatic greeting", "welcome message setup", "first message to customer"}, Remark: "Automation"},
|
||||
{Question: "Can keywords automatically trigger human handoff?", Answer: "Yes. Add handoff rules in bot strategy and enter keywords such as complaint, refund, human agent, or invoice reissue. Include synonyms and common user phrasing to reduce missed matches.", SimilarQuestions: []string{"keyword handoff", "words trigger human agent", "automatic human transfer"}, Remark: "Automation"},
|
||||
{Question: "How do I view conversation volume trends and peak hours?", Answer: "Open Data Reports > Traffic Analysis to view conversations, visitors, queue peaks, and human service rate by hour, date, and channel. Peak-hour analysis should be compared with staffing schedules.", SimilarQuestions: []string{"conversation trend report", "peak traffic hours", "traffic analytics"}, Remark: "Reports"},
|
||||
{Question: "What do bot hit rate and resolution rate mean?", Answer: "Hit rate is the percentage of in-scope bot messages where the bot retrieves and returns an answer. Resolution rate is the percentage of conversations completed without human handoff after bot handling. A high hit rate does not always mean a high resolution rate, so review both metrics together.", SimilarQuestions: []string{"AI hit rate meaning", "bot resolution rate", "bot metrics explanation"}, Remark: "Reports"},
|
||||
{Question: "How do I upgrade a plan or add more seats?", Answer: "Contact your customer success manager or submit an upgrade request in Billing Center > Plan Management. Added seats are usually prorated for the remaining billing cycle, and permissions and quotas are expanded after the upgrade takes effect.", SimilarQuestions: []string{"add seats", "upgrade subscription", "buy more agents"}, Remark: "Billing"},
|
||||
{Question: "Can we issue a VAT invoice?", Answer: "Yes. Maintain complete invoice information in Billing Center > Invoice Information, including company name, tax number, registered address, phone, and bank information. After approval, invoices are issued according to the billing cycle.", SimilarQuestions: []string{"request invoice", "VAT invoice", "invoice information setup"}, Remark: "Billing"},
|
||||
{Question: "What happens after AI usage exceeds the included quota?", Answer: "If your plan includes a fixed AI quota, excess usage is usually billed by actual calls or token usage. Enable usage alerts in the billing center to avoid unexpected overage near month end.", SimilarQuestions: []string{"AI overage billing", "token usage charge", "quota exceeded"}, Remark: "Billing"},
|
||||
{Question: "What should I do if messages are sent but customers do not receive them?", Answer: "Confirm that the message status is successful, the customer channel is online, and the third-party channel has no delivery failure receipt. If only one channel is affected, the cause is often channel throttling, template review, or network issues.", SimilarQuestions: []string{"customer cannot receive message", "message delivery failed", "sent but not received"}, Remark: "Troubleshooting"},
|
||||
{Question: "Why is the page slow or the message list loading slowly?", Answer: "Common causes include too much browser cache, too many conversation tabs open, high network latency, or very large attachments in the page. Refresh the page, close unnecessary tabs, and check the local network and browser version first.", SimilarQuestions: []string{"console is slow", "message list slow", "chat page lag"}, Remark: "Troubleshooting"},
|
||||
{Question: "Why does a newly configured bot not take effect?", Answer: "Check whether the bot is bound to the correct channel, whether service hours cover the current time, and whether the channel is still using human-first service. If configuration looks correct, inspect debug logs to confirm whether routing conditions were matched.", SimilarQuestions: []string{"bot not working", "AI not taking over", "configuration not effective"}, Remark: "Troubleshooting"},
|
||||
{Question: "How do I decide whether an FAQ should be optimized?", Answer: "Prioritize three signals: high hit rate but high human handoff rate, high hit rate but low satisfaction, and repeated customer follow-up questions. These usually mean the answer is incomplete, inconsistent, or missing similar question coverage.", SimilarQuestions: []string{"FAQ optimization signals", "which FAQ to improve", "knowledge base quality review"}, Remark: "Knowledge operations"},
|
||||
{Question: "How long should an FAQ answer be?", Answer: "Start with the conclusion, then add steps and notes. Most support FAQ answers work best at roughly 80 to 220 Chinese characters or the English equivalent. Very short answers miss context; very long answers are harder for bots to cite and customers to read quickly.", SimilarQuestions: []string{"FAQ answer length", "how detailed should answers be", "write Q&A content"}, Remark: "Knowledge operations"},
|
||||
}
|
||||
}
|
||||
|
||||
func chineseKnowledgeFAQSeeds() []faqSeed {
|
||||
return []faqSeed{
|
||||
{Question: "登录后台时提示账号或密码错误怎么办?", Answer: "请先确认账号是否输入完整,邮箱登录要区分公司域名,密码需注意大小写。如果连续输错 5 次,系统会临时锁定 15 分钟。仍无法登录时,可让企业管理员在“组织设置-成员管理”中重置密码,或通过登录页的“忘记密码”重新设置。", SimilarQuestions: []string{"后台密码错误怎么处理", "账号登录不上去怎么办", "提示用户名或密码错误"}, Remark: "账号登录"},
|
||||
{Question: "忘记登录密码后怎么找回?", Answer: "在登录页点击“忘记密码”,输入绑定邮箱后获取验证码即可重置密码。如果账号未绑定邮箱或邮箱无法使用,请联系企业管理员协助重置。为了安全,重置成功后系统会让该账号在所有设备重新登录。", SimilarQuestions: []string{"忘记密码了怎么办", "怎么重置后台密码", "找回登录密码"}, Remark: "账号登录"},
|
||||
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
package kb
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishKnowledgeBaseTextDoesNotContainChineseText(t *testing.T) {
|
||||
name, description, remark := faqKnowledgeBaseText(seedlang.English)
|
||||
for _, value := range []string{name, description, remark} {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english knowledge base text contains Chinese text: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnglishKnowledgeFAQSeedsDoNotContainChineseText(t *testing.T) {
|
||||
seeds := knowledgeFAQSeeds(seedlang.English)
|
||||
if len(seeds) == 0 {
|
||||
t.Fatal("english FAQ seeds are empty")
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
values := []string{seed.Question, seed.Answer, seed.Remark}
|
||||
values = append(values, seed.SimilarQuestions...)
|
||||
for _, value := range values {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english FAQ seed contains Chinese text: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+14
-7
@@ -7,6 +7,7 @@ import (
|
||||
"agent-desk/cmd/testdata/channel"
|
||||
"agent-desk/cmd/testdata/kb"
|
||||
"agent-desk/cmd/testdata/quickreply"
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/cmd/testdata/skill"
|
||||
"agent-desk/cmd/testdata/tag"
|
||||
"agent-desk/internal/bootstrap"
|
||||
@@ -32,8 +33,14 @@ func main() {
|
||||
func run() error {
|
||||
configPath := flag.String("config", "config/config.yaml", "path to config file")
|
||||
autoConfirm := flag.Bool("yes", false, "skip confirmation prompt")
|
||||
langValue := flag.String("lang", string(seedlang.Chinese), "testdata language: zh or en")
|
||||
flag.Parse()
|
||||
|
||||
lang, err := seedlang.Parse(*langValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := confirmDestructiveAction(*autoConfirm); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -71,7 +78,7 @@ func run() error {
|
||||
slog.Int("created", aiConfigResult.Created),
|
||||
slog.Int("updated", aiConfigResult.Updated))
|
||||
|
||||
kbResult, err := kb.Init()
|
||||
kbResult, err := kb.Init(lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init knowledge base failed: %w", err)
|
||||
}
|
||||
@@ -81,13 +88,13 @@ func run() error {
|
||||
slog.Int("updatedFAQs", kbResult.UpdatedFAQs),
|
||||
)
|
||||
|
||||
skillResult, err := skill.Init()
|
||||
skillResult, err := skill.Init(lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init skill failed: %w", err)
|
||||
}
|
||||
slog.Info("skill init success", slog.Int("created", skillResult.Created), slog.Int("updated", skillResult.Updated))
|
||||
|
||||
agentTeamResult, err := agentteam.Init()
|
||||
agentTeamResult, err := agentteam.Init(lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init agent team failed: %w", err)
|
||||
}
|
||||
@@ -97,24 +104,24 @@ func run() error {
|
||||
slog.Int("updatesApplied", agentTeamResult.UpdatesApplied),
|
||||
)
|
||||
|
||||
aiAgentResult, err := aiagent.Init()
|
||||
aiAgentResult, err := aiagent.Init(lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init ai agent failed: %w", err)
|
||||
}
|
||||
slog.Info("ai agent init success", slog.Int("created", aiAgentResult.Created), slog.Int("updated", aiAgentResult.Updated))
|
||||
|
||||
channelResult, err := channel.Init()
|
||||
channelResult, err := channel.Init(lang)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init channel failed: %w", err)
|
||||
}
|
||||
slog.Info("channel init success", slog.Int("created", channelResult.Created), slog.Int("updated", channelResult.Updated))
|
||||
|
||||
if err := tag.Init(); err != nil {
|
||||
if err := tag.Init(lang); err != nil {
|
||||
slog.Error("init tag failed", "error", err)
|
||||
}
|
||||
slog.Info("tag init success")
|
||||
|
||||
if err := quickreply.Init(); err != nil {
|
||||
if err := quickreply.Init(lang); err != nil {
|
||||
return fmt.Errorf("init quick reply failed: %w", err)
|
||||
}
|
||||
slog.Info("quick reply init success")
|
||||
|
||||
Vendored
+141
-51
@@ -1,6 +1,7 @@
|
||||
package quickreply
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
@@ -9,15 +10,146 @@ import (
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
func Init() error {
|
||||
seed := []struct {
|
||||
id int64
|
||||
groupName string
|
||||
title string
|
||||
content string
|
||||
status enums.Status
|
||||
sortNo int
|
||||
}{
|
||||
func Init(lang seedlang.Language) error {
|
||||
seed := seedItems(lang)
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
now := time.Now()
|
||||
for _, row := range seed {
|
||||
existing := repositories.QuickReplyRepository.Get(ctx.Tx, row.id)
|
||||
if existing == nil {
|
||||
item := &models.QuickReply{
|
||||
ID: row.id,
|
||||
GroupName: row.groupName,
|
||||
Title: row.title,
|
||||
Content: row.content,
|
||||
Status: row.status,
|
||||
SortNo: row.sortNo,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 0,
|
||||
CreateUserName: "system",
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: "system",
|
||||
},
|
||||
}
|
||||
if err := repositories.QuickReplyRepository.Create(ctx.Tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := repositories.QuickReplyRepository.Updates(ctx.Tx, row.id, map[string]any{
|
||||
"group_name": row.groupName,
|
||||
"title": row.title,
|
||||
"content": row.content,
|
||||
"status": row.status,
|
||||
"sort_no": row.sortNo,
|
||||
"updated_at": now,
|
||||
"update_user_id": 0,
|
||||
"update_user_name": "system",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type seedItem struct {
|
||||
id int64
|
||||
groupName string
|
||||
title string
|
||||
content string
|
||||
status enums.Status
|
||||
sortNo int
|
||||
}
|
||||
|
||||
func seedItems(lang seedlang.Language) []seedItem {
|
||||
if lang == seedlang.English {
|
||||
return []seedItem{
|
||||
{
|
||||
id: 1,
|
||||
groupName: "New Visitor Reception",
|
||||
title: "First contact greeting",
|
||||
content: "Hello, welcome to AgentDesk support. I am the consultant assisting you today. Tell me what product, pricing, or integration option you want to learn about, and I will help you assess it quickly.",
|
||||
status: enums.StatusOk,
|
||||
sortNo: 100,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
groupName: "Product Inquiry",
|
||||
title: "Product capability overview",
|
||||
content: "We currently support AI Q&A, knowledge base retrieval, human handoff, tag management, quick replies, and agent workspace administration. If you already have a business scenario, I can break down a solution for that scenario.",
|
||||
status: enums.StatusOk,
|
||||
sortNo: 95,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
groupName: "Product Inquiry",
|
||||
title: "Deployment options",
|
||||
content: "The system supports both private deployment and cloud deployment. If you have strong data compliance requirements, evaluate private deployment first. If you want to launch quickly, start with the cloud version.",
|
||||
status: enums.StatusOk,
|
||||
sortNo: 90,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
groupName: "Quotation Follow-up",
|
||||
title: "Information before quotation",
|
||||
content: "To prepare an accurate quote, please share the expected number of agent seats, average daily conversation volume, whether a knowledge base is needed, and whether private deployment is required. I will organize the information and follow up quickly.",
|
||||
status: enums.StatusOk,
|
||||
sortNo: 85,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
groupName: "Quotation Follow-up",
|
||||
title: "Quotation sent reminder",
|
||||
content: "Hello, the solution and quotation have been sent to you. Please review them when convenient. If you want me to walk through feature boundaries, implementation timeline, and delivery approach, I can arrange that directly.",
|
||||
status: enums.StatusOk,
|
||||
sortNo: 80,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
groupName: "Implementation",
|
||||
title: "Confirm details before troubleshooting",
|
||||
content: "Got it. I will help troubleshoot first. Please add the time the issue started, affected scope, exact error screenshot, and whether any configuration was changed recently. This will help us locate the cause faster.",
|
||||
status: enums.StatusOk,
|
||||
sortNo: 75,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
groupName: "Implementation",
|
||||
title: "Configuration effective time",
|
||||
content: "The configuration has been updated and usually takes effect within 1 to 3 minutes. Please refresh the page and run another test. If anything is still abnormal, I will continue following up.",
|
||||
status: enums.StatusOk,
|
||||
sortNo: 70,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
groupName: "After-sales Support",
|
||||
title: "Issue escalation notice",
|
||||
content: "I have recorded this issue and escalated it to the technical team. The current priority is marked as high. We expect to provide the first conclusion today, and I will update you as soon as there is progress.",
|
||||
status: enums.StatusOk,
|
||||
sortNo: 65,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
groupName: "After-sales Support",
|
||||
title: "Version update notice template",
|
||||
content: "Hello, a version update is scheduled for Thursday evening. It mainly includes knowledge retrieval optimization and workspace experience improvements. There may be brief fluctuations during the update, and we will prepare rollback plans in advance.",
|
||||
status: enums.StatusOk,
|
||||
sortNo: 60,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
groupName: "Customer Follow-up",
|
||||
title: "Trial period follow-up",
|
||||
content: "Hello, I would like to check your trial experience over the past few days. Which features are used most often? Have you encountered anything hard to understand, complex to configure, or unstable in effect?",
|
||||
status: enums.StatusOk,
|
||||
sortNo: 55,
|
||||
},
|
||||
}
|
||||
}
|
||||
return []seedItem{
|
||||
{
|
||||
id: 1,
|
||||
groupName: "新客接待",
|
||||
@@ -99,46 +231,4 @@ func Init() error {
|
||||
sortNo: 55,
|
||||
},
|
||||
}
|
||||
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
now := time.Now()
|
||||
for _, row := range seed {
|
||||
existing := repositories.QuickReplyRepository.Get(ctx.Tx, row.id)
|
||||
if existing == nil {
|
||||
item := &models.QuickReply{
|
||||
ID: row.id,
|
||||
GroupName: row.groupName,
|
||||
Title: row.title,
|
||||
Content: row.content,
|
||||
Status: row.status,
|
||||
SortNo: row.sortNo,
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 0,
|
||||
CreateUserName: "system",
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: "system",
|
||||
},
|
||||
}
|
||||
if err := repositories.QuickReplyRepository.Create(ctx.Tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := repositories.QuickReplyRepository.Updates(ctx.Tx, row.id, map[string]any{
|
||||
"group_name": row.groupName,
|
||||
"title": row.title,
|
||||
"content": row.content,
|
||||
"status": row.status,
|
||||
"sort_no": row.sortNo,
|
||||
"updated_at": now,
|
||||
"update_user_id": 0,
|
||||
"update_user_name": "system",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
package quickreply
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishSeedItemsMatchChineseCount(t *testing.T) {
|
||||
englishItems := seedItems(seedlang.English)
|
||||
chineseItems := seedItems(seedlang.Chinese)
|
||||
|
||||
if len(englishItems) != len(chineseItems) {
|
||||
t.Fatalf("english seed count = %d, want %d", len(englishItems), len(chineseItems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnglishSeedItemsDoNotContainChineseText(t *testing.T) {
|
||||
for _, item := range seedItems(seedlang.English) {
|
||||
for _, value := range []string{item.groupName, item.title, item.content} {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english quick reply %d contains Chinese text: %q", item.id, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
package seedlang
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Language string
|
||||
|
||||
const (
|
||||
Chinese Language = "zh"
|
||||
English Language = "en"
|
||||
)
|
||||
|
||||
func Parse(raw string) (Language, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch normalized {
|
||||
case "", "zh", "zh-cn", "chinese":
|
||||
return Chinese, nil
|
||||
case "en", "en-us", "english":
|
||||
return English, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported testdata language %q, supported: zh, en", raw)
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package seedlang
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseDefaultsToChinese(t *testing.T) {
|
||||
lang, err := Parse("")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse empty returned error: %v", err)
|
||||
}
|
||||
if lang != Chinese {
|
||||
t.Fatalf("Parse empty = %q, want %q", lang, Chinese)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEnglishAliases(t *testing.T) {
|
||||
for _, raw := range []string{"en", "EN", "english", "English"} {
|
||||
lang, err := Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse(%q) returned error: %v", raw, err)
|
||||
}
|
||||
if lang != English {
|
||||
t.Fatalf("Parse(%q) = %q, want %q", raw, lang, English)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsUnsupportedLanguage(t *testing.T) {
|
||||
if _, err := Parse("fr"); err == nil {
|
||||
t.Fatal("Parse unsupported language returned nil error")
|
||||
}
|
||||
}
|
||||
Vendored
+56
-3
@@ -1,6 +1,7 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
@@ -17,9 +18,9 @@ type InitResult struct {
|
||||
Updated int
|
||||
}
|
||||
|
||||
func Init() (*InitResult, error) {
|
||||
func Init(lang seedlang.Language) (*InitResult, error) {
|
||||
result := &InitResult{}
|
||||
seedItems := buildSeedItems()
|
||||
seedItems := buildSeedItems(lang)
|
||||
for _, item := range seedItems {
|
||||
itemCopy := item
|
||||
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
@@ -43,8 +44,60 @@ func Init() (*InitResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildSeedItems() []models.SkillDefinition {
|
||||
func buildSeedItems(lang seedlang.Language) []models.SkillDefinition {
|
||||
now := time.Now()
|
||||
if lang == seedlang.English {
|
||||
return []models.SkillDefinition{
|
||||
{
|
||||
Code: AfterSalesEscalationSkillCode,
|
||||
Name: "After-sales Escalation",
|
||||
Description: "Handles incidents, complaints, after-sales follow-up, ticket creation, and human handoff requests. Match only when the user clearly needs after-sales intervention or escalation; do not match ordinary greetings, product introductions, or general inquiries.",
|
||||
Instruction: `You are the dedicated "After-sales Escalation" skill responsible for customer support requests that require escalation.
|
||||
|
||||
Scope:
|
||||
1. Handle only these scenarios: incidents, complaints, unresolved issues, explicit ticket creation requests, explicit human handoff requests, or after-sales follow-up.
|
||||
2. If the user is only asking a general question, discussing product usage, greeting, or chatting casually, state that the current request is outside this skill's scope and avoid misclassifying it as an escalation.
|
||||
|
||||
Rules:
|
||||
1. First determine whether the user has clearly requested escalation. If not, ask concise follow-up questions in English, such as order number, product name, issue symptoms, actions already tried, and desired handling method.
|
||||
2. If the user explicitly asks to create or submit a ticket, prioritize the ticket flow and do not switch to human handoff on your own.
|
||||
3. If the user complains, reports an incident, or asks for after-sales follow-up but the information is scattered, first call graph/prepare_ticket_draft to organize a ticket draft, then ask for missing fields.
|
||||
4. Only call graph/create_ticket_with_confirmation when the user explicitly wants to submit a ticket, complaint, or incident report and the title and description are clear enough.
|
||||
5. Only call graph/handoff_to_human when the user explicitly requests a human agent, or when you determine that a human must continue and the request is not suitable for direct ticket creation.
|
||||
6. If the user mentions both "ticket" and "human agent", clarify the priority. If the user clearly says "create a ticket", assist with ticket creation first unless they explicitly ask again for immediate human handoff.
|
||||
7. Never claim in text that a ticket has been created or a human handoff has happened. Those actions must be performed through the corresponding tools.
|
||||
8. If there is not enough information for ticket creation or handoff, ask for clarification before taking an escalation action.
|
||||
|
||||
Response requirements:
|
||||
1. Use English throughout. Keep the tone professional, concise, and like a real support agent.
|
||||
2. Focus on issue diagnosis and escalation handling. Do not output unrelated self-introductions.
|
||||
3. When entering a confirmation flow, clearly tell the user you will help submit or transfer the request and wait for the confirmation result.`,
|
||||
Examples: `[
|
||||
"My device went offline today and restarting did not help. Please create a ticket.",
|
||||
"I confirm that I want to create a ticket, not transfer to a human agent.",
|
||||
"This issue has not been resolved for three days. I want to file a complaint.",
|
||||
"Please transfer me to a human agent. You cannot solve this.",
|
||||
"When will after-sales support contact me? No one has followed up on this failure.",
|
||||
"Help me report an incident. The product model is AX300 and it cannot connect to the network.",
|
||||
"I need after-sales support. This issue keeps happening."
|
||||
]`,
|
||||
ToolWhitelist: `[
|
||||
"graph/create_ticket_with_confirmation",
|
||||
"graph/handoff_to_human"
|
||||
]`,
|
||||
Status: enums.StatusOk,
|
||||
Remark: "after-sales escalation skill",
|
||||
AuditFields: models.AuditFields{
|
||||
CreatedAt: now,
|
||||
CreateUserID: 0,
|
||||
CreateUserName: "System",
|
||||
UpdatedAt: now,
|
||||
UpdateUserID: 0,
|
||||
UpdateUserName: "System",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return []models.SkillDefinition{
|
||||
{
|
||||
Code: AfterSalesEscalationSkillCode,
|
||||
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
package skill
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishSkillSeedDoesNotContainChineseText(t *testing.T) {
|
||||
for _, item := range buildSeedItems(seedlang.English) {
|
||||
values := []string{item.Name, item.Description, item.Instruction, item.Examples, item.ToolWhitelist, item.Remark}
|
||||
for _, value := range values {
|
||||
if hanTextPattern.MatchString(value) {
|
||||
t.Fatalf("english skill seed contains Chinese text: %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+37
-19
@@ -1,6 +1,7 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
@@ -9,24 +10,8 @@ import (
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
func Init() error {
|
||||
seed := []struct {
|
||||
id int64
|
||||
parentID int64
|
||||
name string
|
||||
sortNo int
|
||||
}{
|
||||
{1, 0, "售前", 1},
|
||||
{2, 1, "AgentDesk", 1},
|
||||
{3, 2, "产品咨询", 1},
|
||||
{4, 2, "购买意向", 1},
|
||||
{5, 0, "售后", 2},
|
||||
{6, 5, "AgentDesk", 1},
|
||||
{7, 6, "问题反馈", 1},
|
||||
{8, 6, "产品部署", 2},
|
||||
{9, 6, "需求工单", 3},
|
||||
}
|
||||
|
||||
func Init(lang seedlang.Language) error {
|
||||
seed := seedItems(lang)
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
now := time.Now()
|
||||
for _, row := range seed {
|
||||
@@ -68,5 +53,38 @@ func Init() error {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type seedItem struct {
|
||||
id int64
|
||||
parentID int64
|
||||
name string
|
||||
sortNo int
|
||||
}
|
||||
|
||||
func seedItems(lang seedlang.Language) []seedItem {
|
||||
if lang == seedlang.English {
|
||||
return []seedItem{
|
||||
{1, 0, "Pre-sales", 1},
|
||||
{2, 1, "AgentDesk", 1},
|
||||
{3, 2, "Product Inquiry", 1},
|
||||
{4, 2, "Purchase Intent", 1},
|
||||
{5, 0, "After-sales", 2},
|
||||
{6, 5, "AgentDesk", 1},
|
||||
{7, 6, "Issue Feedback", 1},
|
||||
{8, 6, "Product Deployment", 2},
|
||||
{9, 6, "Feature Request", 3},
|
||||
}
|
||||
}
|
||||
return []seedItem{
|
||||
{1, 0, "售前", 1},
|
||||
{2, 1, "AgentDesk", 1},
|
||||
{3, 2, "产品咨询", 1},
|
||||
{4, 2, "购买意向", 1},
|
||||
{5, 0, "售后", 2},
|
||||
{6, 5, "AgentDesk", 1},
|
||||
{7, 6, "问题反馈", 1},
|
||||
{8, 6, "产品部署", 2},
|
||||
{9, 6, "需求工单", 3},
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"agent-desk/cmd/testdata/seedlang"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var hanTextPattern = regexp.MustCompile(`\p{Han}`)
|
||||
|
||||
func TestEnglishTagSeedsDoNotContainChineseText(t *testing.T) {
|
||||
for _, item := range seedItems(seedlang.English) {
|
||||
if hanTextPattern.MatchString(item.name) {
|
||||
t.Fatalf("english tag seed contains Chinese text: %q", item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user