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.
This commit is contained in:
2026-10-16 15:21:42 +00:00
parent 299ea50fb5
commit 2f7a491666
176 changed files with 11869 additions and 2716 deletions

View File

@@ -1,3 +1,4 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
@@ -15,6 +16,7 @@ import (
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/repositories"
)
@@ -24,6 +26,9 @@ type answer struct {
var Answer = &answer{}
func (s *answer) DebugSearch(ctx context.Context, req request.KnowledgeSearchRequest) (*response.KnowledgeSearchResponse, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if strings.TrimSpace(req.Question) == "" {
return nil, errorsx.InvalidParamI18n("error.e0340")
}
@@ -59,6 +64,9 @@ func (s *answer) DebugSearch(ctx context.Context, req request.KnowledgeSearchReq
}
func (s *answer) DebugAnswer(ctx context.Context, req request.KnowledgeAnswerRequest, operator *dto.AuthPrincipal) (*response.KnowledgeAnswerResponse, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if strings.TrimSpace(req.Question) == "" {
return nil, errorsx.InvalidParamI18n("error.e0340")
}
@@ -77,7 +85,7 @@ func (s *answer) DebugAnswer(ctx context.Context, req request.KnowledgeAnswerReq
}
retrieveMs := time.Since(retrieveStartedAt).Milliseconds()
knowledgeBase := s.resolveAnswerKnowledgeBase(req.KnowledgeBaseIDs, results)
contextResults := buildContextHits(Retrieve.SelectContextResults(results, 4000))
contextResults := buildContextHits(Retrieve.SelectContextResultsWithContext(ctx, results, 4000))
hits := make([]response.KnowledgeSearchResult, 0, len(results))
topScore := 0.0
@@ -114,36 +122,18 @@ func (s *answer) DebugAnswer(ctx context.Context, req request.KnowledgeAnswerReq
fallbackMode := enums.AIAgentFallbackModeNoAnswer
answerStatus := enums.KnowledgeAnswerStatusNormal
answer := ""
modelName := ""
promptTokens := 0
completionTokens := 0
generateStartedAt := time.Now()
if len(hits) == 0 {
answerStatus = enums.KnowledgeAnswerStatusNoAnswer
answer = buildFallbackAnswer(fallbackMode)
} else {
contextText := Retrieve.BuildContext(ctx, results, 4000)
systemPrompt := buildAnswerSystemPrompt(answerMode)
userPrompt := fmt.Sprintf("用户问题:%s\n\n参考资料:\n%s", req.Question, contextText)
llmResult, llmErr := ai.LLM.Chat(ctx, systemPrompt, userPrompt)
if llmErr != nil {
answerStatus = enums.KnowledgeAnswerStatusFallback
answer = buildFallbackAnswer(fallbackMode)
} else {
answer = llmResult.Content
modelName = llmResult.ModelName
promptTokens = llmResult.PromptTokens
completionTokens = llmResult.CompletionTokens
if strings.TrimSpace(answer) == "" {
answerStatus = enums.KnowledgeAnswerStatusFallback
answer = buildFallbackAnswer(fallbackMode)
}
}
generated, err := generateKnowledgeAnswer(ctx, req.Question, results, answerMode, fallbackMode, ai.LLM.Chat)
if err != nil {
return nil, err
}
answerStatus := generated.Status
answer := generated.Content
modelName := generated.ModelName
promptTokens := generated.PromptTokens
completionTokens := generated.CompletionTokens
generateMs := time.Since(generateStartedAt).Milliseconds()
rerankLimit := 0
chunkProvider := ""
chunkTargetTokens := 0
@@ -191,7 +181,7 @@ func (s *answer) DebugAnswer(ctx context.Context, req request.KnowledgeAnswerReq
Question: req.Question,
Answer: answer,
AnswerStatus: int(answerStatus),
AnswerStatusName: getAnswerStatusName(answerStatus),
AnswerStatusName: getAnswerStatusName(ctx, answerStatus),
Citations: citations,
Hits: hits,
HitCount: len(hits),
@@ -376,20 +366,20 @@ func resolveDefaultRerankLimit(items []models.KnowledgeBase) int {
return limit
}
func buildAnswerSystemPrompt(answerMode enums.KnowledgeAnswerMode) string {
func buildAnswerSystemPrompt(ctx context.Context, answerMode enums.KnowledgeAnswerMode) string {
key := "knowledge.rag.system.strict"
if answerMode == enums.KnowledgeAnswerModeAssist {
return "你是客服知识库助手。请优先依据提供的知识片段回答,可以做轻度归纳,但不要编造未提供的事实。"
key = "knowledge.rag.system.assist"
}
return "你是严格的客服知识库助手。只能依据提供的知识片段回答;如果资料不足,请明确说明知识库暂无明确信息。"
return i18nx.Getf(i18nx.LocaleFromContext(ctx), key)
}
func buildFallbackAnswer(fallbackMode enums.AIAgentFallbackMode) string {
switch fallbackMode {
case enums.AIAgentFallbackModeSuggestRetry:
return "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。"
default:
return "当前知识库暂无明确信息。"
func buildFallbackAnswer(ctx context.Context, fallbackMode enums.AIAgentFallbackMode) string {
key := "knowledge.rag.fallback.noAnswer"
if fallbackMode == enums.AIAgentFallbackModeSuggestRetry {
key = "knowledge.rag.fallback.suggestRetry"
}
return i18nx.Getf(i18nx.LocaleFromContext(ctx), key)
}
func defaultRetrieveChannel(channel string) string {
@@ -406,17 +396,52 @@ func defaultRetrieveScene(scene string) string {
return scene
}
func getAnswerStatusName(status enums.KnowledgeAnswerStatus) string {
func getAnswerStatusName(ctx context.Context, status enums.KnowledgeAnswerStatus) string {
key := "knowledge.labels.unknown"
switch status {
case enums.KnowledgeAnswerStatusNormal:
return "正常"
case enums.KnowledgeAnswerStatusNoAnswer:
return "无答案"
case enums.KnowledgeAnswerStatusFallback:
return "兜底"
case enums.KnowledgeAnswerStatusBlocked:
return "风控拦截"
default:
return "未知"
case enums.KnowledgeAnswerStatusNormal, enums.KnowledgeAnswerStatusNoAnswer, enums.KnowledgeAnswerStatusFallback, enums.KnowledgeAnswerStatusBlocked:
key = fmt.Sprintf("knowledge.labels.answer.%d", status)
}
return i18nx.Getf(i18nx.LocaleFromContext(ctx), key)
}
type generatedKnowledgeAnswer struct {
ai.ChatCompletionResult
Status enums.KnowledgeAnswerStatus
}
type knowledgeChatFunc func(context.Context, string, string) (*ai.ChatCompletionResult, error)
// Keep generation independent of persistence so fallback and cancellation paths
// can be tested without a configured model, external service, or database.
func generateKnowledgeAnswer(ctx context.Context, question string, results []RetrieveResult, mode enums.KnowledgeAnswerMode, fallbackMode enums.AIAgentFallbackMode, chat knowledgeChatFunc) (*generatedKnowledgeAnswer, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
result := &generatedKnowledgeAnswer{Status: enums.KnowledgeAnswerStatusNoAnswer}
result.Content = buildFallbackAnswer(ctx, fallbackMode)
if len(results) == 0 {
return result, nil
}
contextText := Retrieve.BuildContext(ctx, results, 4000)
systemPrompt := buildAnswerSystemPrompt(ctx, mode)
userPrompt := i18nx.Getf(i18nx.LocaleFromContext(ctx), "knowledge.rag.userPrompt", question, contextText)
llmResult, err := chat(ctx, systemPrompt, userPrompt)
// An interrupted request is not a model failure and must not be logged as a
// completed fallback answer after the client has gone away.
if contextErr := ctx.Err(); contextErr != nil {
return nil, contextErr
}
if err != nil || llmResult == nil {
result.Status = enums.KnowledgeAnswerStatusFallback
return result, nil
}
result.ChatCompletionResult = *llmResult
if strings.TrimSpace(llmResult.Content) == "" {
result.Status = enums.KnowledgeAnswerStatusFallback
result.Content = buildFallbackAnswer(ctx, fallbackMode)
return result, nil
}
result.Status = enums.KnowledgeAnswerStatusNormal
return result, nil
}

View File

@@ -1,8 +1,12 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
"context"
"testing"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
@@ -27,17 +31,17 @@ func TestBuildFallbackAnswer(t *testing.T) {
}
for _, tt := range tests {
if got := buildFallbackAnswer(tt.mode); got != tt.expected {
if got := buildFallbackAnswer(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), tt.mode); got != tt.expected {
t.Fatalf("%s: expected %q, got %q", tt.name, tt.expected, got)
}
}
}
func TestGetAnswerStatusName(t *testing.T) {
if got := getAnswerStatusName(enums.KnowledgeAnswerStatusNoAnswer); got != "无答案" {
if got := getAnswerStatusName(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), enums.KnowledgeAnswerStatusNoAnswer); got != "无答案" {
t.Fatalf("expected no-answer label, got %q", got)
}
if got := getAnswerStatusName(enums.KnowledgeAnswerStatusFallback); got != "兜底" {
if got := getAnswerStatusName(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), enums.KnowledgeAnswerStatusFallback); got != i18nx.Getf(i18nx.LocaleZhCN, "knowledge.labels.answer.3") {
t.Fatalf("expected fallback label, got %q", got)
}
}

View File

@@ -0,0 +1,82 @@
package rag
import (
"context"
"encoding/json"
"fmt"
"strings"
"agent-desk/internal/models"
"agent-desk/internal/pkg/i18nx"
)
// faqContent keeps application-owned labels separate from source text. Never
// translate, trim, or replace substrings in the source question or answer.
type faqContent struct {
question string
similarQuestions []string
answer string
}
func sourceFAQContent(faq models.KnowledgeFAQ) faqContent {
content := faqContent{question: faq.Question, answer: faq.Answer}
if faq.SimilarQuestions != "" {
_ = json.Unmarshal([]byte(faq.SimilarQuestions), &content.similarQuestions)
}
return content
}
func (content faqContent) render(locale string) string {
parts := []string{i18nx.Getf(locale, "knowledge.rag.faq.question", content.question)}
if len(content.similarQuestions) > 0 {
similar := joinNonEmptySimilarQuestions(content.similarQuestions, i18nx.Getf(locale, "knowledge.rag.faq.separator"))
parts = append(parts, i18nx.Getf(locale, "knowledge.rag.faq.similarQuestions", similar))
}
parts = append(parts, i18nx.Getf(locale, "knowledge.rag.faq.answer", content.answer))
return strings.Join(parts, "\n")
}
// Index generation deliberately uses the configured default, not the locale of
// whichever administrator requested a build. A locale read never re-embeds data.
func buildFAQChunkContent(faq models.KnowledgeFAQ) string {
return sourceFAQContent(faq).render(i18nx.DefaultLocale)
}
// legacyChinese renders the exact pre-localization serialization. This is a
// recognition format, not UI copy. Keep it frozen when catalog wording changes.
func (content faqContent) legacyChinese() string {
parts := []string{fmt.Sprintf("问题:%s", content.question)}
if len(content.similarQuestions) > 0 {
parts = append(parts, "相似问:"+joinNonEmptySimilarQuestions(content.similarQuestions, ";"))
}
parts = append(parts, "回答:"+content.answer)
return strings.Join(parts, "\n")
}
// Only a full byte-for-byte match against a generated representation of the
// current source proves which text belongs to us. Stale/manual/malformed chunks
// stay as recorded. In particular, do not infer labels by splitting on Chinese
// words: the source content may itself contain those words or arbitrary lines.
// This function is for live retrieval only, never historical log replay.
func renderVerifiedFAQChunkContent(ctx context.Context, stored string, faq models.KnowledgeFAQ) string {
content := sourceFAQContent(faq)
if stored == content.legacyChinese() {
return content.render(i18nx.LocaleFromContext(ctx))
}
for _, locale := range []string{i18nx.LocaleEnUS, i18nx.LocaleRuRU, i18nx.LocaleZhCN} {
if stored == content.render(locale) {
return content.render(i18nx.LocaleFromContext(ctx))
}
}
return stored
}
func joinNonEmptySimilarQuestions(items []string, separator string) string {
nonEmpty := make([]string, 0, len(items))
for _, item := range items {
if item != "" {
nonEmpty = append(nonEmpty, item)
}
}
return strings.Join(nonEmpty, separator)
}

View File

@@ -1,10 +1,10 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"time"
@@ -47,6 +47,9 @@ var Index = &index{
}
func (s *index) IndexDocumentByID(ctx context.Context, documentID int64) error {
if err := ctx.Err(); err != nil {
return err
}
document, err := s.loadDocumentByID(documentID)
if err != nil {
return err
@@ -55,6 +58,9 @@ func (s *index) IndexDocumentByID(ctx context.Context, documentID int64) error {
}
func (s *index) IndexDocument(ctx context.Context, document models.KnowledgeDocument) error {
if err := ctx.Err(); err != nil {
return err
}
start := time.Now()
if err := s.markDocumentIndexPending(document.ID); err != nil {
slog.Error("Failed to mark knowledge document index as pending", "document_id", document.ID, "error", err)
@@ -92,6 +98,9 @@ func (s *index) IndexDocument(ctx context.Context, document models.KnowledgeDocu
}
func (s *index) IndexFAQByID(ctx context.Context, faqID int64) error {
if err := ctx.Err(); err != nil {
return err
}
faq, err := s.loadFAQByID(faqID)
if err != nil {
return err
@@ -288,43 +297,6 @@ func (s *index) RebuildKnowledgeBaseIndex(ctx context.Context, knowledgeBaseID i
return nil
}
func buildFAQChunkContent(faq models.KnowledgeFAQ) string {
parts := []string{fmt.Sprintf("问题:%s", faq.Question)}
var similarQuestions []string
if faq.SimilarQuestions != "" {
_ = json.Unmarshal([]byte(faq.SimilarQuestions), &similarQuestions)
}
if len(similarQuestions) > 0 {
parts = append(parts, fmt.Sprintf("相似问:%s", joinSimilarQuestions(similarQuestions)))
}
parts = append(parts, fmt.Sprintf("回答:%s", faq.Answer))
content := ""
for _, part := range parts {
if part == "" {
continue
}
if content != "" {
content += "\n"
}
content += part
}
return content
}
func joinSimilarQuestions(items []string) string {
result := ""
for _, item := range items {
if item == "" {
continue
}
if result != "" {
result += ";"
}
result += item
}
return result
}
func (s *index) resetKnowledgeBaseIndexStorage(ctx context.Context, knowledgeBaseID int64) error {
chunks := repositories.KnowledgeChunkRepository.FindByKnowledgeBaseID(sqls.DB(), knowledgeBaseID)
return s.cleanupKnowledgeBaseChunks(ctx, knowledgeBaseID, chunks)

View File

@@ -1,9 +1,13 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
"context"
"strings"
"testing"
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/models"
)
@@ -14,7 +18,7 @@ func TestBuildFAQChunkContent(t *testing.T) {
Answer: "在订单页发起退款。",
}
content := buildFAQChunkContent(faq)
content := sourceFAQContent(faq).render(i18nx.LocaleZhCN)
if !strings.Contains(content, "问题:如何退款") {
t.Fatalf("expected question in content, got %q", content)
}
@@ -70,7 +74,7 @@ func TestNormalizeContextResultsMergesAndDedupes(t *testing.T) {
}
func TestBuildContextChunkText(t *testing.T) {
faqText := buildContextChunkText(RetrieveResult{
faqText := buildContextChunkText(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), RetrieveResult{
FaqID: 1,
FaqQuestion: "如何退款",
Content: "在订单页发起退款。",
@@ -79,7 +83,7 @@ func TestBuildContextChunkText(t *testing.T) {
t.Fatalf("unexpected faq context text: %q", faqText)
}
docText := buildContextChunkText(RetrieveResult{
docText := buildContextChunkText(i18nx.WithLocale(context.Background(), i18nx.LocaleZhCN), RetrieveResult{
DocumentID: 2,
DocumentTitle: "退款文档",
SectionPath: "售后/退款",

View File

@@ -0,0 +1,175 @@
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)
}
}

View File

@@ -1,3 +1,4 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
@@ -32,6 +33,9 @@ type RetrieveTrace struct {
func (s *retrieve) RetrieveWithTrace(ctx context.Context, req RetrieveRequest) ([]RetrieveResult, *RetrieveTrace, error) {
trace := newRetrieveTrace()
if err := ctx.Err(); err != nil {
return nil, trace, err
}
retrievableKnowledgeBases, _, ok := s.prepareRetrievableKnowledgeBases(req, trace)
if !ok {
return nil, trace, nil
@@ -47,7 +51,7 @@ func (s *retrieve) RetrieveWithTrace(ctx context.Context, req RetrieveRequest) (
if len(searchResults) == 0 {
return nil, trace, nil
}
results, hydrateMs := s.hydrateRetrieveResults(searchResults)
results, hydrateMs := s.hydrateRetrieveResults(ctx, searchResults)
trace.HydrateMs = hydrateMs
return results, trace, nil

View File

@@ -1,12 +1,19 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
"context"
"fmt"
"strings"
"agent-desk/internal/pkg/i18nx"
)
func (s *retrieve) SelectContextResults(results []RetrieveResult, maxTokens int) []RetrieveResult {
return s.SelectContextResultsWithContext(context.Background(), results, maxTokens)
}
func (s *retrieve) SelectContextResultsWithContext(ctx context.Context, results []RetrieveResult, maxTokens int) []RetrieveResult {
if len(results) == 0 {
return nil
}
@@ -20,7 +27,7 @@ func (s *retrieve) SelectContextResults(results []RetrieveResult, maxTokens int)
if documentUsage[item.DocumentID] >= 2 {
continue
}
chunkText := buildContextChunkText(item)
chunkText := buildContextChunkText(ctx, item)
estimatedTokens := len(chunkText) / 2
if totalTokens+estimatedTokens > maxTokens {
break
@@ -32,15 +39,15 @@ func (s *retrieve) SelectContextResults(results []RetrieveResult, maxTokens int)
return selected
}
func (s *retrieve) BuildContext(_ context.Context, results []RetrieveResult, maxTokens int) string {
func (s *retrieve) BuildContext(ctx context.Context, results []RetrieveResult, maxTokens int) string {
if len(results) == 0 {
return ""
}
normalizedResults := s.SelectContextResults(results, maxTokens)
normalizedResults := s.SelectContextResultsWithContext(ctx, results, maxTokens)
var builder strings.Builder
for _, r := range normalizedResults {
builder.WriteString(buildContextChunkText(r))
builder.WriteString(buildContextChunkText(ctx, r))
}
return builder.String()
@@ -125,26 +132,27 @@ func buildSectionKey(item RetrieveResult) string {
return fmt.Sprintf("%d|chunk:%d", item.DocumentID, item.ChunkNo)
}
func buildContextChunkText(item RetrieveResult) string {
func buildContextChunkText(ctx context.Context, item RetrieveResult) string {
locale := i18nx.LocaleFromContext(ctx)
if item.FaqID > 0 {
title := strings.TrimSpace(item.FaqQuestion)
if title == "" {
title = strings.TrimSpace(item.Title)
}
if title == "" {
title = fmt.Sprintf("FAQ#%d", item.FaqID)
title = i18nx.Getf(locale, "knowledge.rag.context.faqTitle", item.FaqID)
}
return fmt.Sprintf("【FAQ:%s】\n%s\n\n", title, item.Content)
return i18nx.Getf(locale, "knowledge.rag.context.faq", title, item.Content)
}
title := strings.TrimSpace(item.DocumentTitle)
if title == "" {
title = fmt.Sprintf("文档#%d", item.DocumentID)
title = i18nx.Getf(locale, "knowledge.rag.context.documentTitle", item.DocumentID)
}
if item.SectionPath != "" {
return fmt.Sprintf("【文档:%s|章节:%s】\n%s\n\n", title, item.SectionPath, item.Content)
return i18nx.Getf(locale, "knowledge.rag.context.documentSection", title, item.SectionPath, item.Content)
}
if item.Title != "" {
return fmt.Sprintf("【文档:%s|标题:%s】\n%s\n\n", title, item.Title, item.Content)
return i18nx.Getf(locale, "knowledge.rag.context.documentHeading", title, item.Title, item.Content)
}
return fmt.Sprintf("【文档:%s】\n%s\n\n", title, item.Content)
return i18nx.Getf(locale, "knowledge.rag.context.document", title, item.Content)
}

View File

@@ -1,3 +1,4 @@
// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
@@ -72,7 +73,7 @@ func (s *retrieve) searchKnowledgeBaseVectors(ctx context.Context, req RetrieveR
return searchResults, trace, nil
}
func (s *retrieve) hydrateRetrieveResults(searchResults []vectordb.SearchResult) ([]RetrieveResult, int64) {
func (s *retrieve) hydrateRetrieveResults(ctx context.Context, searchResults []vectordb.SearchResult) ([]RetrieveResult, int64) {
if len(searchResults) == 0 {
return nil, 0
}
@@ -128,6 +129,7 @@ func (s *retrieve) hydrateRetrieveResults(searchResults []vectordb.SearchResult)
documentTitle := ""
faqQuestion := ""
content := chunk.Content
if chunk.DocumentID > 0 {
document := documentByID[chunk.DocumentID]
if document == nil || document.Status != enums.StatusOk {
@@ -141,6 +143,7 @@ func (s *retrieve) hydrateRetrieveResults(searchResults []vectordb.SearchResult)
continue
}
faqQuestion = faq.Question
content = renderVerifiedFAQChunkContent(ctx, chunk.Content, *faq)
}
results = append(results, RetrieveResult{
@@ -153,7 +156,7 @@ func (s *retrieve) hydrateRetrieveResults(searchResults []vectordb.SearchResult)
ChunkNo: chunk.ChunkNo,
Title: chunk.Title,
SectionPath: chunk.SectionPath,
Content: chunk.Content,
Content: content,
Score: sr.Score,
ChunkType: extractChunkType(sr.Payload),
})