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
}