Files
agent-desk-ng/internal/ai/rag/localization_test.go
mono 2f7a491666 feat: harden support, localize knowledge and add operator availability [skip ci]
Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
2026-10-16 15:21:42 +00:00

176 lines
9.1 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 rag
import (
"context"
"errors"
"strings"
"testing"
"agent-desk/internal/ai"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/i18nx"
)
func TestKnowledgeAnswerLocalizedFallbackPaths(t *testing.T) {
tests := []struct{ locale, fallback, retry, noAnswerStatus string }{
{i18nx.LocaleEnUS, "The knowledge base currently has no clear information.", "The knowledge base did not contain enough clear information. Try asking a more specific question.", "No answer"},
{i18nx.LocaleRuRU, "В базе знаний пока нет однозначной информации.", "В базе знаний недостаточно однозначной информации. Попробуйте задать более конкретный вопрос.", "Нет ответа"},
{i18nx.LocaleZhCN, "当前知识库暂无明确信息。", "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。", "无答案"},
}
for _, tt := range tests {
t.Run(tt.locale, func(t *testing.T) {
ctx := i18nx.WithLocale(context.Background(), tt.locale)
called := false
failedChat := func(context.Context, string, string) (*ai.ChatCompletionResult, error) {
called = true
return nil, errors.New("provider failed")
}
noHits, err := generateKnowledgeAnswer(ctx, "问题 не переводить", nil, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, failedChat)
if err != nil || called || noHits.Status != enums.KnowledgeAnswerStatusNoAnswer || noHits.Content != tt.fallback {
t.Fatalf("no-hit result = %#v, %v; model called = %v", noHits, err, called)
}
if got := getAnswerStatusName(ctx, noHits.Status); got != tt.noAnswerStatus {
t.Fatalf("status = %q", got)
}
hits := []RetrieveResult{{DocumentID: 1, Content: "Reference content"}}
failed, err := generateKnowledgeAnswer(ctx, "Q", hits, enums.KnowledgeAnswerModeAssist, enums.AIAgentFallbackModeNoAnswer, failedChat)
if err != nil || !called || failed.Status != enums.KnowledgeAnswerStatusFallback || failed.Content != tt.fallback {
t.Fatalf("failed model result = %#v, %v", failed, err)
}
for _, empty := range []*ai.ChatCompletionResult{nil, {}, {Content: " \n\t"}} {
got, err := generateKnowledgeAnswer(ctx, "Q", hits, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeSuggestRetry, func(context.Context, string, string) (*ai.ChatCompletionResult, error) { return empty, nil })
if err != nil || got.Status != enums.KnowledgeAnswerStatusFallback || got.Content != tt.retry {
t.Fatalf("empty result fallback = %#v, %v", got, err)
}
}
})
}
}
func TestKnowledgeGenerationPreservesUserAndModelContent(t *testing.T) {
for _, locale := range []string{i18nx.LocaleEnUS, i18nx.LocaleRuRU, i18nx.LocaleZhCN} {
ctx := i18nx.WithLocale(context.Background(), locale)
question := " 问题:User's question\n回答:not a wrapper "
body := " 回答:body\n相似问:literal user text\n "
output := &ai.ChatCompletionResult{Content: " 模型回答\n", ModelName: "test-model", PromptTokens: 4, CompletionTokens: 2}
got, err := generateKnowledgeAnswer(ctx, question, []RetrieveResult{{DocumentID: 1, DocumentTitle: "用户标题", Content: body}}, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, func(modelCtx context.Context, systemPrompt, userPrompt string) (*ai.ChatCompletionResult, error) {
if i18nx.LocaleFromContext(modelCtx) != locale {
t.Fatal("locale not forwarded to model")
}
if systemPrompt != i18nx.Getf(locale, "knowledge.rag.system.strict") || !strings.Contains(userPrompt, question) || !strings.Contains(userPrompt, body) {
t.Fatalf("incorrect prompts: %q, %q", systemPrompt, userPrompt)
}
return output, nil
})
if err != nil || got.Status != enums.KnowledgeAnswerStatusNormal || got.ChatCompletionResult != *output {
t.Fatalf("model output was changed: %#v, %v", got, err)
}
}
}
func TestKnowledgeCancellationBeforePersistenceOrProviderCalls(t *testing.T) {
ctx, cancel := context.WithCancel(i18nx.WithLocale(context.Background(), i18nx.LocaleRuRU))
cancel()
if _, err := Answer.DebugSearch(ctx, request.KnowledgeSearchRequest{Question: "Q"}); !errors.Is(err, context.Canceled) {
t.Fatalf("debug search ignored cancellation: %v", err)
}
if _, err := Answer.DebugAnswer(ctx, request.KnowledgeAnswerRequest{Question: "Q"}, nil); !errors.Is(err, context.Canceled) {
t.Fatalf("debug answer ignored cancellation: %v", err)
}
if err := Index.IndexDocumentByID(ctx, 1); !errors.Is(err, context.Canceled) {
t.Fatalf("document build ignored cancellation: %v", err)
}
if err := Index.IndexFAQByID(ctx, 1); !errors.Is(err, context.Canceled) {
t.Fatalf("FAQ build ignored cancellation: %v", err)
}
if _, err := Retrieve.Retrieve(ctx, RetrieveRequest{Query: "Q"}); !errors.Is(err, context.Canceled) {
t.Fatalf("retrieval ignored cancellation: %v", err)
}
}
func TestKnowledgeGenerationCancellationIsNotFallback(t *testing.T) {
ctx, cancel := context.WithCancel(i18nx.WithLocale(context.Background(), i18nx.LocaleRuRU))
defer cancel()
got, err := generateKnowledgeAnswer(ctx, "Q", []RetrieveResult{{DocumentID: 1, Content: "source"}}, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, func(modelCtx context.Context, _, _ string) (*ai.ChatCompletionResult, error) {
cancel()
return nil, modelCtx.Err()
})
if got != nil || !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled model should not become a saved fallback: %#v, %v", got, err)
}
}
func TestFAQGeneratedWrappersPreserveSourceContent(t *testing.T) {
faq := models.KnowledgeFAQ{
Question: " 问题:What?\n回答:part of question ",
SimilarQuestions: `["相似问:literal; text", "", " another\n问题:line "]`,
Answer: " 回答:user answer\n相似问:inside answer\n问题:last line ",
}
original := faq
source := sourceFAQContent(faq)
for _, locale := range []string{i18nx.LocaleEnUS, i18nx.LocaleRuRU, i18nx.LocaleZhCN} {
ctx := i18nx.WithLocale(context.Background(), locale)
expected := source.render(locale)
for _, stored := range []string{source.legacyChinese(), source.render(i18nx.LocaleEnUS), source.render(i18nx.LocaleRuRU), source.render(i18nx.LocaleZhCN)} {
got := renderVerifiedFAQChunkContent(ctx, stored, faq)
if got != expected || !strings.Contains(got, faq.Question) || !strings.Contains(got, faq.Answer) || !strings.Contains(got, " another\n问题:line ") {
t.Fatalf("%s changed source text: %q", locale, got)
}
}
}
if faq != original {
t.Fatal("source model mutated")
}
if got := buildFAQChunkContent(faq); got != source.render(i18nx.DefaultLocale) {
t.Fatalf("index did not use configured locale: %q", got)
}
}
func TestFAQUnknownAndStaleDerivedContentStaysRecorded(t *testing.T) {
faq := models.KnowledgeFAQ{Question: "Current Q", Answer: "Current A"}
ctx := i18nx.WithLocale(context.Background(), i18nx.LocaleRuRU)
for _, stored := range []string{
"问题:Old Q\n回答:Historical A",
"问题:Current Q\n回答:Current A\n",
"问题:Current Q\n回答:User-authored custom answer",
"相似问:not a generated chunk",
"{malformed:derived}",
"",
} {
if got := renderVerifiedFAQChunkContent(ctx, stored, faq); got != stored {
t.Fatalf("unverified stored content was rewritten: %q => %q", stored, got)
}
}
}
func TestContextWrappersAreLocalizedWithoutChangingSource(t *testing.T) {
content := " 原始文档\n回答:literal\n "
tests := []struct{ locale, label string }{
{i18nx.LocaleEnUS, "[Document: Document#7 | Section: 用户章节]"},
{i18nx.LocaleRuRU, "[Документ: Документ №7 | Раздел: 用户章节]"},
{i18nx.LocaleZhCN, "【文档:文档#7|章节:用户章节】"},
}
for _, tt := range tests {
ctx := i18nx.WithLocale(context.Background(), tt.locale)
got := Retrieve.BuildContext(ctx, []RetrieveResult{{DocumentID: 7, SectionPath: "用户章节", Content: content}}, 4000)
if got != tt.label+"\n"+content+"\n\n" {
t.Fatalf("%s unexpected context: %q", tt.locale, got)
}
}
}
func TestKnowledgeGenerationDefaultLocaleAndEmptyResponseMetrics(t *testing.T) {
ctx := context.Background()
noHits, err := generateKnowledgeAnswer(ctx, "Q", nil, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, nil)
if err != nil || noHits.Content != i18nx.Get("knowledge.rag.fallback.noAnswer") {
t.Fatalf("default-locale caller changed behavior: %#v, %v", noHits, err)
}
empty := &ai.ChatCompletionResult{Content: " \n", ModelName: "local-test-model", PromptTokens: 12, CompletionTokens: 3}
got, err := generateKnowledgeAnswer(ctx, "Q", []RetrieveResult{{DocumentID: 1, Content: "reference"}}, enums.KnowledgeAnswerModeStrict, enums.AIAgentFallbackModeNoAnswer, func(context.Context, string, string) (*ai.ChatCompletionResult, error) { return empty, nil })
if err != nil || got.Status != enums.KnowledgeAnswerStatusFallback || got.Content != noHits.Content || got.ModelName != empty.ModelName || got.PromptTokens != empty.PromptTokens || got.CompletionTokens != empty.CompletionTokens {
t.Fatalf("empty completion must retain usage metrics: %#v, %v", got, err)
}
}