Files
agent-desk-ng/internal/ai/rag/answer.go

423 lines
12 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"
"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/repositories"
)
type answer struct {
}
var Answer = &answer{}
func (s *answer) DebugSearch(ctx context.Context, req request.KnowledgeSearchRequest) (*response.KnowledgeSearchResponse, error) {
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 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.SelectContextResults(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
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)
}
}
}
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(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(answerMode enums.KnowledgeAnswerMode) string {
if answerMode == enums.KnowledgeAnswerModeAssist {
return "你是客服知识库助手。请优先依据提供的知识片段回答,可以做轻度归纳,但不要编造未提供的事实。"
}
return "你是严格的客服知识库助手。只能依据提供的知识片段回答;如果资料不足,请明确说明知识库暂无明确信息。"
}
func buildFallbackAnswer(fallbackMode enums.AIAgentFallbackMode) string {
switch fallbackMode {
case enums.AIAgentFallbackModeSuggestRetry:
return "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。"
default:
return "当前知识库暂无明确信息。"
}
}
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(status enums.KnowledgeAnswerStatus) string {
switch status {
case enums.KnowledgeAnswerStatusNormal:
return "正常"
case enums.KnowledgeAnswerStatusNoAnswer:
return "无答案"
case enums.KnowledgeAnswerStatusFallback:
return "兜底"
case enums.KnowledgeAnswerStatusBlocked:
return "风控拦截"
default:
return "未知"
}
}