Files
kefu/im/backend/internal/app/ai_agent_test.go
T
Your NameandClaude Opus 5 a024d59827 后端接入 AI 模型并支持测试账号托管回复
按协议而不是按厂商做适配,与现有短信、对象存储的做法一致:openai 兼容格式
一个适配器即可覆盖 DeepSeek、通义、智谱、火山方舟、Ollama 等,新增厂商通常
只需在后台加一条模型记录;anthropic、gemini、webhook、debug 各一个适配器。
密钥沿用 integration.go 的 AES-GCM 加密存储。

测试账号托管的回复走 persistMessage 同一条落库和推送路径,因此未读数、
WebSocket 推送和会话排序全部复用现有逻辑,客户端无需改动。为此把
persistMessage 抽出 persistMessageContext,因为 worker 没有 *http.Request。

任务队列用数据库表而非内存:重启不丢回复,多实例不重复消费。领取用 UPDATE
打 claim_token 再回读,没有用 SELECT ... FOR UPDATE SKIP LOCKED——生产存在
MySQL 5.7 环境,那里该语法无法解析。同一会话同时只允许一个待处理任务
(pending_key 生成列 + 唯一键),所以用户连发多条消息只会得到一条回复。

闸门:仅 is_test=1 且在允许批次内的账号生效,托管账号之间不互相触发,三层
配额,调用失败默认静默。会话内是否标注 AI 身份由 ai.disclose_in_chat 控制
并默认开启,关闭前需确认所在地区的监管要求。

app.go、integration.go、im.go 三个文件同时包含本次改动之前工作区里就已存在
的未提交修改,一并带入。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 08:31:04 +08:00

88 lines
3.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"strings"
"testing"
)
func TestSanitizeAIReplyFlattensAssistantFormatting(t *testing.T) {
tests := []struct {
name, input, want string
}{
{name: "trim", input: " 在的 ", want: "在的"},
{name: "markdown", input: "**在的**__刚看到__", want: "在的,刚看到"},
{name: "bullets", input: "- 第一点\n- 第二点", want: "第一点 第二点"},
{name: "numbered", input: "1. 先这样\n2. 再那样", want: "先这样 再那样"},
{name: "quote", input: "> 引用\n正文", want: "引用 正文"},
{name: "code fence", input: "好的\n```go\nfmt.Println()\n```", want: "好的"},
{name: "blank", input: "\n\n \n", want: ""},
}
for _, test := range tests {
if got := sanitizeAIReply(test.input); got != test.want {
t.Errorf("sanitizeAIReply(%s) = %q, want %q", test.name, got, test.want)
}
}
long := strings.Repeat("字", aiReplyMaxRunes+40)
if got := []rune(sanitizeAIReply(long)); len(got) != aiReplyMaxRunes {
t.Errorf("a long reply must be cut to %d runes, got %d", aiReplyMaxRunes, len(got))
}
}
func TestAISystemPromptFallsBackToProfile(t *testing.T) {
binding := aiAgentBinding{Nickname: "季若宁", Age: 27, City: "青岛", Bio: "喜欢电影与绘画"}
prompt := aiSystemPrompt(binding, true)
for _, fragment := range []string{"季若宁", "27 岁", "青岛", "喜欢电影与绘画"} {
if !strings.Contains(prompt, fragment) {
t.Errorf("profile-derived persona must mention %q: %s", fragment, prompt)
}
}
// Disclosure is a monitored requirement, so it has to be switchable and
// present by default.
if !strings.Contains(prompt, "AI 助手") {
t.Error("disclosure must add the honesty rule")
}
if strings.Contains(aiSystemPrompt(binding, false), "如果对方询问") {
t.Error("disclosure off must drop the rule")
}
custom := aiSystemPrompt(aiAgentBinding{Persona: "你是一个健身教练", Nickname: "忽略我"}, false)
if !strings.Contains(custom, "健身教练") || strings.Contains(custom, "忽略我") {
t.Errorf("an explicit persona must replace the profile text: %s", custom)
}
if !strings.Contains(custom, "不索取或提供手机号") {
t.Error("safety rules must survive a custom persona")
}
}
func TestAIMessageTextDegradesMedia(t *testing.T) {
text, _ := json.Marshal(map[string]any{"text": " 你好 "})
if got := aiMessageText(1, text); got != "你好" {
t.Errorf("text message = %q", got)
}
if got := aiMessageText(2, []byte(`{"url":"https://example.com/a.jpg"}`)); got != "[图片]" {
t.Errorf("image placeholder = %q", got)
}
if got := aiMessageText(3, []byte(`{"url":"https://example.com/a.m4a","duration":3}`)); got != "[语音]" {
t.Errorf("audio placeholder = %q", got)
}
if got := aiMessageText(1, []byte(`not json`)); got != "" {
t.Errorf("a broken body must be skipped, got %q", got)
}
}
func TestAIBatchAllowList(t *testing.T) {
if !aiBatchAllowed("", "any-batch") || !aiBatchAllowed("", "") {
t.Error("an empty allow list must accept every test account")
}
if !aiBatchAllowed("batch-a, batch-b", "batch-b") {
t.Error("a listed batch must be accepted")
}
if aiBatchAllowed("batch-a", "batch-c") {
t.Error("an unlisted batch must be refused")
}
// An account with no batch must not slip through a non-empty allow list.
if aiBatchAllowed("batch-a", "") {
t.Error("an empty batch must not match a configured allow list")
}
}