Files
agent-desk-ng/internal/ai/rag/answer.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

448 lines
14 KiB
Go

// Modified in the independent AgentDesk fork: localized knowledge workflows and safe display errors.
package rag
import (
"context"
"fmt"
"strings"
"time"
"github.com/mlogclub/simple/sqls"
"agent-desk/internal/ai"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"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"
)
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")
}
startedAt := time.Now()
results, err := s.retrieve(req, ctx)
if err != nil {
return nil, err
}
respResults := make([]response.KnowledgeSearchResult, 0, len(results))
for _, item := range results {
respResults = append(respResults, response.KnowledgeSearchResult{
KnowledgeBaseID: item.KnowledgeBaseID,
ChunkID: item.ChunkID,
DocumentID: item.DocumentID,
DocumentTitle: item.DocumentTitle,
FaqID: item.FaqID,
FaqQuestion: item.FaqQuestion,
ChunkNo: item.ChunkNo,
Title: item.Title,
SectionPath: item.SectionPath,
Content: item.Content,
Score: float64(item.Score),
})
}
return &response.KnowledgeSearchResponse{
Question: req.Question,
Results: respResults,
HitCount: len(respResults),
LatencyMs: time.Since(startedAt).Milliseconds(),
}, nil
}
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")
}
startedAt := time.Now()
retrieveStartedAt := time.Now()
results, err := s.retrieve(request.KnowledgeSearchRequest{
KnowledgeBaseIDs: req.KnowledgeBaseIDs,
Question: req.Question,
TopK: req.TopK,
ScoreThreshold: req.ScoreThreshold,
RerankLimit: req.RerankLimit,
}, ctx)
if err != nil {
return nil, err
}
retrieveMs := time.Since(retrieveStartedAt).Milliseconds()
knowledgeBase := s.resolveAnswerKnowledgeBase(req.KnowledgeBaseIDs, results)
contextResults := buildContextHits(Retrieve.SelectContextResultsWithContext(ctx, results, 4000))
hits := make([]response.KnowledgeSearchResult, 0, len(results))
topScore := 0.0
for i, item := range results {
score := float64(item.Score)
if i == 0 {
topScore = score
}
hits = append(hits, response.KnowledgeSearchResult{
KnowledgeBaseID: item.KnowledgeBaseID,
ChunkID: item.ChunkID,
DocumentID: item.DocumentID,
DocumentTitle: item.DocumentTitle,
FaqID: item.FaqID,
FaqQuestion: item.FaqQuestion,
ChunkNo: item.ChunkNo,
Title: item.Title,
SectionPath: item.SectionPath,
Content: item.Content,
Score: score,
})
}
citations := buildKnowledgeCitations(hits, 3)
answerMode := enums.KnowledgeAnswerMode(req.AnswerMode)
if answerMode == 0 {
if knowledgeBase != nil {
answerMode = enums.KnowledgeAnswerMode(knowledgeBase.AnswerMode)
}
if answerMode == 0 {
answerMode = enums.KnowledgeAnswerModeStrict
}
}
fallbackMode := enums.AIAgentFallbackModeNoAnswer
generateStartedAt := time.Now()
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
chunkMaxTokens := 0
chunkOverlapTokens := 0
if knowledgeBase != nil {
rerankLimit = resolveRerankLimit(req.RerankLimit, knowledgeBase.DefaultRerankLimit)
chunkProvider = knowledgeBase.ChunkProvider
chunkTargetTokens = knowledgeBase.ChunkTargetTokens
chunkMaxTokens = knowledgeBase.ChunkMaxTokens
chunkOverlapTokens = knowledgeBase.ChunkOverlapTokens
}
logItem, err := RetrieveLog.CreateRetrieveLog(&CreateRetrieveLogRequest{
KnowledgeBaseID: firstKnowledgeBaseID(req.KnowledgeBaseIDs),
Channel: defaultRetrieveChannel(req.Channel),
Scene: defaultRetrieveScene(req.Scene),
SessionID: req.SessionID,
ConversationID: req.ConversationID,
Question: req.Question,
RewriteQuestion: "",
Answer: answer,
AnswerStatus: int(answerStatus),
ChunkProvider: chunkProvider,
ChunkTargetTokens: chunkTargetTokens,
ChunkMaxTokens: chunkMaxTokens,
ChunkOverlapTokens: chunkOverlapTokens,
RerankEnabled: rerankLimit > 0,
RerankLimit: rerankLimit,
Hits: hits,
UsedHits: contextResults,
Citations: citations,
LatencyMs: time.Since(startedAt).Milliseconds(),
RetrieveMs: retrieveMs,
GenerateMs: generateMs,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
ModelName: modelName,
}, operator)
if err != nil {
return nil, err
}
return &response.KnowledgeAnswerResponse{
Question: req.Question,
Answer: answer,
AnswerStatus: int(answerStatus),
AnswerStatusName: getAnswerStatusName(ctx, answerStatus),
Citations: citations,
Hits: hits,
HitCount: len(hits),
TopScore: topScore,
LatencyMs: time.Since(startedAt).Milliseconds(),
RetrieveMs: retrieveMs,
GenerateMs: generateMs,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
ModelName: modelName,
RetrieveLogID: logItem.ID,
}, nil
}
func buildContextHits(results []RetrieveResult) []response.KnowledgeSearchResult {
if len(results) == 0 {
return nil
}
hits := make([]response.KnowledgeSearchResult, 0, len(results))
for _, item := range results {
hits = append(hits, response.KnowledgeSearchResult{
KnowledgeBaseID: item.KnowledgeBaseID,
ChunkID: item.ChunkID,
DocumentID: item.DocumentID,
DocumentTitle: item.DocumentTitle,
FaqID: item.FaqID,
FaqQuestion: item.FaqQuestion,
ChunkNo: item.ChunkNo,
Title: item.Title,
SectionPath: item.SectionPath,
Content: item.Content,
Score: float64(item.Score),
})
}
return hits
}
func buildKnowledgeCitations(hits []response.KnowledgeSearchResult, limit int) []response.KnowledgeCitation {
if len(hits) == 0 || limit <= 0 {
return nil
}
citations := make([]response.KnowledgeCitation, 0, limit)
seen := make(map[string]struct{})
for _, item := range hits {
key := fmt.Sprintf("%d|%d|%s|%d", item.DocumentID, item.FaqID, item.SectionPath, item.ChunkNo)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
citations = append(citations, response.KnowledgeCitation{
DocumentID: item.DocumentID,
DocumentTitle: item.DocumentTitle,
FaqID: item.FaqID,
FaqQuestion: item.FaqQuestion,
ChunkNo: item.ChunkNo,
Title: item.Title,
SectionPath: item.SectionPath,
Snippet: truncateCitationSnippet(item.Content, 120),
Score: item.Score,
})
if len(citations) >= limit {
break
}
}
return citations
}
func truncateCitationSnippet(text string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(strings.TrimSpace(text))
if len(runes) <= limit {
return string(runes)
}
return string(runes[:limit]) + "..."
}
func (s *answer) BuildDocumentIndex(ctx context.Context, documentID int64) error {
return Index.IndexDocumentByID(ctx, documentID)
}
func (s *answer) retrieve(req request.KnowledgeSearchRequest, ctx context.Context) ([]RetrieveResult, error) {
if len(normalizeKnowledgeBaseIDs(req.KnowledgeBaseIDs)) == 0 {
return nil, errorsx.InvalidParamI18n("error.e0284")
}
knowledgeBases := s.loadKnowledgeBases(req.KnowledgeBaseIDs)
results, err := Retrieve.Retrieve(ctx, RetrieveRequest{
KnowledgeBaseIDs: req.KnowledgeBaseIDs,
Query: req.Question,
TopK: req.TopK,
ScoreThreshold: req.ScoreThreshold,
})
if err != nil {
return nil, err
}
defaultRerankLimit := resolveDefaultRerankLimit(knowledgeBases)
rerankLimit := resolveRerankLimit(req.RerankLimit, defaultRerankLimit)
if rerankLimit > 0 && len(results) > rerankLimit {
return Retrieve.RetrieveWithRerank(ctx, RetrieveRequest{
KnowledgeBaseIDs: req.KnowledgeBaseIDs,
Query: req.Question,
TopK: req.TopK,
ScoreThreshold: req.ScoreThreshold,
}, rerankLimit)
}
return results, nil
}
func (s *answer) loadKnowledgeBases(knowledgeBaseIDs []int64) []models.KnowledgeBase {
normalized := normalizeKnowledgeBaseIDs(knowledgeBaseIDs)
if len(normalized) == 0 {
return nil
}
items := repositories.KnowledgeBaseRepository.Find(sqls.DB(), sqls.NewCnd().In("id", normalized))
if len(items) == 0 {
return nil
}
itemMap := make(map[int64]models.KnowledgeBase, len(items))
for _, item := range items {
itemMap[item.ID] = item
}
results := make([]models.KnowledgeBase, 0, len(normalized))
for _, id := range normalized {
if item, ok := itemMap[id]; ok {
results = append(results, item)
}
}
return results
}
func (s *answer) resolvePrimaryKnowledgeBase(knowledgeBaseIDs []int64) *models.KnowledgeBase {
items := s.loadKnowledgeBases(knowledgeBaseIDs)
for _, item := range items {
return &item
}
return nil
}
func (s *answer) resolveAnswerKnowledgeBase(knowledgeBaseIDs []int64, results []RetrieveResult) *models.KnowledgeBase {
items := s.loadKnowledgeBases(knowledgeBaseIDs)
if len(items) == 0 {
return nil
}
if len(results) > 0 {
for _, item := range items {
if item.ID == results[0].KnowledgeBaseID {
return &item
}
}
}
return &items[0]
}
func firstKnowledgeBaseID(ids []int64) int64 {
normalized := normalizeKnowledgeBaseIDs(ids)
if len(normalized) == 0 {
return 0
}
return normalized[0]
}
func resolveRerankLimit(requestLimit int, defaultLimit int) int {
if requestLimit > 0 {
return requestLimit
}
if defaultLimit > 0 {
return defaultLimit
}
return 0
}
func resolveDefaultRerankLimit(items []models.KnowledgeBase) int {
limit := 0
for _, item := range items {
if item.DefaultRerankLimit > limit {
limit = item.DefaultRerankLimit
}
}
return limit
}
func buildAnswerSystemPrompt(ctx context.Context, answerMode enums.KnowledgeAnswerMode) string {
key := "knowledge.rag.system.strict"
if answerMode == enums.KnowledgeAnswerModeAssist {
key = "knowledge.rag.system.assist"
}
return i18nx.Getf(i18nx.LocaleFromContext(ctx), key)
}
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 {
if strings.TrimSpace(channel) == "" {
return string(enums.KnowledgeRetrieveChannelDebug)
}
return channel
}
func defaultRetrieveScene(scene string) string {
if strings.TrimSpace(scene) == "" {
return string(enums.KnowledgeRetrieveSceneQA)
}
return scene
}
func getAnswerStatusName(ctx context.Context, status enums.KnowledgeAnswerStatus) string {
key := "knowledge.labels.unknown"
switch status {
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
}