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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
82
internal/ai/rag/faq_content.go
Normal file
82
internal/ai/rag/faq_content.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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: "售后/退款",
|
||||
|
||||
175
internal/ai/rag/localization_test.go
Normal file
175
internal/ai/rag/localization_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user