440 lines
16 KiB
Go
440 lines
16 KiB
Go
package qa
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
|
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/core"
|
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/reasoningmode"
|
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/session"
|
|
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/weknora"
|
|
)
|
|
|
|
type AgentResolver interface {
|
|
Resolve(channel, externalChatID string) (string, error)
|
|
}
|
|
|
|
type WeKnora interface {
|
|
CreateSession(ctx context.Context, title, description string) (string, error)
|
|
UpdateSessionTitle(ctx context.Context, sessionID, title, description string) error
|
|
AgentChat(ctx context.Context, sessionID string, request weknora.AgentChatRequest) (weknora.ChatResult, error)
|
|
}
|
|
|
|
type streamingWeKnora interface {
|
|
AgentChatStream(ctx context.Context, sessionID string, request weknora.AgentChatRequest, observer weknora.AnswerObserver) (weknora.ChatResult, error)
|
|
}
|
|
|
|
type eventStreamingWeKnora interface {
|
|
AgentChatEvents(ctx context.Context, sessionID string, request weknora.AgentChatRequest, observer weknora.StreamObserver) (weknora.ChatResult, error)
|
|
}
|
|
|
|
type sessionInvalidator interface {
|
|
InvalidateSession(ctx context.Context, sessionKey, expectedSessionID string) error
|
|
}
|
|
|
|
// SucceededTurnObserver observes a successfully finished turn. It is called
|
|
// synchronously after FinishTurn succeeds and before the QAResult is returned.
|
|
// A non-nil error from ObserveSucceededTurn must never change the QAResult or
|
|
// cause Ask to return an error.
|
|
type SucceededTurnObserver interface {
|
|
ObserveSucceededTurn(ctx context.Context, turn core.Turn) error
|
|
}
|
|
|
|
type ReasoningModeFinalizer interface {
|
|
Complete(ctx context.Context, input reasoningmode.TurnCompletion) (reasoningmode.Completion, error)
|
|
}
|
|
|
|
// ServiceOption configures a Service.
|
|
type ServiceOption func(*Service)
|
|
|
|
// WithSucceededTurnObserver registers an observer that is called synchronously
|
|
// after each successful turn finishes. onError, if non-nil, is called with any
|
|
// error returned by the observer. The observer error is never returned to the
|
|
// caller.
|
|
func WithSucceededTurnObserver(observer SucceededTurnObserver, onError func(error)) ServiceOption {
|
|
return func(s *Service) {
|
|
s.observerConfigured = true
|
|
s.observer = observer
|
|
s.observerErrorHandler = onError
|
|
}
|
|
}
|
|
|
|
func WithReasoningModeFinalizer(finalizer ReasoningModeFinalizer, onError ...func(error)) ServiceOption {
|
|
return func(s *Service) {
|
|
s.reasoningFinalizer = finalizer
|
|
if len(onError) > 0 {
|
|
s.reasoningErrorHandler = onError[0]
|
|
}
|
|
}
|
|
}
|
|
|
|
type Service struct {
|
|
repository core.Repository
|
|
agents AgentResolver
|
|
weknora WeKnora
|
|
groupPolicy session.GroupPolicy
|
|
observer SucceededTurnObserver
|
|
observerErrorHandler func(error)
|
|
observerConfigured bool
|
|
reasoningFinalizer ReasoningModeFinalizer
|
|
reasoningErrorHandler func(error)
|
|
}
|
|
|
|
func New(repository core.Repository, agents AgentResolver, client WeKnora, groupPolicy session.GroupPolicy, opts ...ServiceOption) (*Service, error) {
|
|
if repository == nil || agents == nil || client == nil {
|
|
return nil, errors.New("repository, agent resolver and weknora client are required")
|
|
}
|
|
if groupPolicy == "" {
|
|
groupPolicy = session.GroupShared
|
|
}
|
|
s := &Service{repository: repository, agents: agents, weknora: client, groupPolicy: groupPolicy}
|
|
for _, o := range opts {
|
|
o(s)
|
|
}
|
|
if s.observerConfigured && isNilSucceededTurnObserver(s.observer) {
|
|
return nil, errors.New("succeeded turn observer is required when configured")
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func isNilSucceededTurnObserver(observer SucceededTurnObserver) bool {
|
|
if observer == nil {
|
|
return true
|
|
}
|
|
v := reflect.ValueOf(observer)
|
|
return v.Kind() == reflect.Pointer && v.IsNil()
|
|
}
|
|
|
|
func (s *Service) Ask(ctx context.Context, request core.QARequest) (core.QAResult, error) {
|
|
return s.ask(ctx, request, nil, nil)
|
|
}
|
|
|
|
func (s *Service) AskStream(ctx context.Context, request core.QARequest, observer weknora.AnswerObserver) (core.QAResult, error) {
|
|
return s.ask(ctx, request, observer, nil)
|
|
}
|
|
|
|
func (s *Service) AskEvents(ctx context.Context, request core.QARequest, observer weknora.StreamObserver) (core.QAResult, error) {
|
|
return s.ask(ctx, request, nil, observer)
|
|
}
|
|
|
|
func (s *Service) RecordDelivery(ctx context.Context, update core.DeliveryUpdate) error {
|
|
return s.repository.RecordDelivery(ctx, update)
|
|
}
|
|
|
|
func (s *Service) ask(ctx context.Context, request core.QARequest, answerObserver weknora.AnswerObserver, eventObserver weknora.StreamObserver) (core.QAResult, error) {
|
|
request = normalizeRequestIdentity(request)
|
|
if err := validateRequest(request); err != nil {
|
|
return core.QAResult{}, err
|
|
}
|
|
baseAgentID, err := s.agents.Resolve(request.Channel, request.ExternalChatID)
|
|
if err != nil {
|
|
return core.QAResult{}, err
|
|
}
|
|
executionAgentID := baseAgentID
|
|
if override := strings.TrimSpace(request.AgentIDOverride); override != "" {
|
|
executionAgentID = override
|
|
}
|
|
sessionKey, subjectID, err := session.BuildKey(session.Identity{
|
|
TenantKey: request.TenantKey, Channel: request.Channel, AgentID: baseAgentID,
|
|
ConversationType: request.ConversationType, ExternalChatID: request.ExternalChatID,
|
|
ExternalUserID: request.ExternalUserID, GroupPolicy: s.groupPolicy,
|
|
})
|
|
if err != nil {
|
|
return core.QAResult{}, err
|
|
}
|
|
|
|
media := make([]core.MediaAudit, 0, len(request.Images))
|
|
questionImageURLs := make([]string, 0, len(request.Images))
|
|
for _, image := range request.Images {
|
|
media = append(media, core.MediaAudit{
|
|
Type: "image", MIMEType: image.MIMEType, SizeBytes: image.SizeBytes,
|
|
Width: image.Width, Height: image.Height,
|
|
})
|
|
if url := strings.TrimSpace(image.PublishedURL); url != "" {
|
|
questionImageURLs = append(questionImageURLs, url)
|
|
}
|
|
}
|
|
messageType := normalizeMessageType(request)
|
|
if messageType == "" {
|
|
return core.QAResult{}, errors.New("unsupported message_type")
|
|
}
|
|
turn := core.Turn{
|
|
ID: newID(),
|
|
TenantKey: request.TenantKey,
|
|
Channel: request.Channel,
|
|
ExternalMessageID: request.ExternalMessageID,
|
|
SessionKey: sessionKey,
|
|
AgentID: executionAgentID,
|
|
ConversationType: request.ConversationType,
|
|
ConversationTitle: request.ConversationTitle,
|
|
ExternalChatID: request.ExternalChatID,
|
|
ExternalUserID: request.ExternalUserID,
|
|
SenderName: request.SenderName,
|
|
TriggerPersonSnapshot: request.TriggerPersonSnapshot,
|
|
ActorUnionIDSnapshot: request.ActorUnionIDSnapshot,
|
|
DepartmentSnapshot: request.Department,
|
|
RegionSnapshot: request.Region,
|
|
MessageType: messageType,
|
|
ActorID: newID(),
|
|
ConversationID: newID(),
|
|
RequestMessageID: newID(),
|
|
Question: request.Query,
|
|
Media: media,
|
|
QuestionImageURLs: questionImageURLs,
|
|
Status: "processing",
|
|
}
|
|
stored, created, err := s.repository.BeginTurn(ctx, turn)
|
|
if err != nil {
|
|
return core.QAResult{}, fmt.Errorf("begin turn: %w", err)
|
|
}
|
|
if !created {
|
|
if stored.Status == "succeeded" {
|
|
return core.QAResult{TurnID: stored.ID, WeKnoraSessionID: stored.WeKnoraSessionID,
|
|
WeKnoraRequestID: stored.WeKnoraRequestID, Answer: stored.Answer,
|
|
AnswerMessageID: stored.AnswerMessageID, DeliveryStatus: stored.AnswerDeliveryStatus,
|
|
References: stored.References, Replayed: true}, nil
|
|
}
|
|
return core.QAResult{}, core.ErrTurnInProgress
|
|
}
|
|
turn = stored
|
|
|
|
displayTitle := session.BuildDisplayTitle(session.TitleIdentity{
|
|
ConversationType: request.ConversationType,
|
|
ExternalUserID: request.ExternalUserID,
|
|
ExternalChatID: request.ExternalChatID,
|
|
SenderName: request.SenderName,
|
|
ConversationTitle: request.ConversationTitle,
|
|
})
|
|
mapping := core.SessionMapping{SessionKey: sessionKey, TenantKey: request.TenantKey,
|
|
Channel: request.Channel, AgentID: baseAgentID, ConversationType: request.ConversationType,
|
|
ExternalSubjectID: subjectID, DisplayTitle: displayTitle}
|
|
createSession := func(createCtx context.Context) (string, error) {
|
|
return s.weknora.CreateSession(createCtx, displayTitle, "Managed by DobotAI; business state remains outside WeKnora")
|
|
}
|
|
sessionID, err := s.repository.ResolveSession(ctx, mapping, createSession)
|
|
if err != nil {
|
|
turn.Status, turn.ErrorMessage = "failed", err.Error()
|
|
_ = s.repository.FinishTurn(ctx, turn)
|
|
return core.QAResult{}, fmt.Errorf("resolve weknora session: %w", err)
|
|
}
|
|
turn.WeKnoraSessionID = sessionID
|
|
_ = s.repository.SyncSessionTitle(ctx, mapping, displayTitle, func(updateCtx context.Context, existingSessionID, title string) error {
|
|
return s.weknora.UpdateSessionTitle(updateCtx, existingSessionID, title, "Managed by DobotAI; business state remains outside WeKnora")
|
|
})
|
|
|
|
query := localizedQuery(request.Query, request.ResponseLocale)
|
|
if request.ConversationType == core.ConversationGroup && strings.TrimSpace(request.SenderName) != "" {
|
|
query = request.SenderName + ": " + query
|
|
}
|
|
images := make([]weknora.Image, 0, len(request.Images))
|
|
for _, image := range request.Images {
|
|
images = append(images, weknora.Image{Data: image.DataURI})
|
|
}
|
|
chatRequest := weknora.AgentChatRequest{
|
|
Query: query, AgentID: executionAgentID, AgentEnabled: true, Channel: "im", EnableMemory: true,
|
|
Images: images,
|
|
}
|
|
// Track whether this turn actually triggered a knowledge-base search by
|
|
// observing knowledge_search tool-call events. The flag feeds coverage
|
|
// classification (image turns) and is persisted with the turn.
|
|
var knowledgeSearched bool
|
|
effectiveEventObserver := weknora.StreamObserver(func(e weknora.StreamEvent) {
|
|
if isKnowledgeTool(e.ToolName) {
|
|
knowledgeSearched = true
|
|
}
|
|
if eventObserver != nil {
|
|
eventObserver(e)
|
|
}
|
|
})
|
|
runChat := func(targetSessionID string) (weknora.ChatResult, error) {
|
|
var result weknora.ChatResult
|
|
var runErr error
|
|
if eventObserver != nil {
|
|
// Prefer AgentChatEvents; fallback to AgentChatStream adaptation; then AgentChat.
|
|
if eventStream, ok := s.weknora.(eventStreamingWeKnora); ok {
|
|
result, runErr = eventStream.AgentChatEvents(ctx, targetSessionID, chatRequest, effectiveEventObserver)
|
|
} else if streaming, ok := s.weknora.(streamingWeKnora); ok {
|
|
// Adapt AgentChatStream answer chunks to StreamEvent
|
|
result, runErr = streaming.AgentChatStream(ctx, targetSessionID, chatRequest, func(full string) {
|
|
if answerObserver != nil {
|
|
answerObserver(full)
|
|
}
|
|
effectiveEventObserver(weknora.StreamEvent{
|
|
ResponseType: "answer",
|
|
FullAnswer: full,
|
|
})
|
|
})
|
|
} else {
|
|
result, runErr = s.weknora.AgentChat(ctx, targetSessionID, chatRequest)
|
|
if runErr == nil && strings.TrimSpace(result.Answer) != "" {
|
|
if answerObserver != nil {
|
|
answerObserver(result.Answer)
|
|
}
|
|
effectiveEventObserver(weknora.StreamEvent{
|
|
ResponseType: "answer",
|
|
FullAnswer: result.Answer,
|
|
Done: true,
|
|
})
|
|
}
|
|
}
|
|
} else if answerObserver != nil {
|
|
if streaming, ok := s.weknora.(streamingWeKnora); ok {
|
|
result, runErr = streaming.AgentChatStream(ctx, targetSessionID, chatRequest, answerObserver)
|
|
} else {
|
|
result, runErr = s.weknora.AgentChat(ctx, targetSessionID, chatRequest)
|
|
if runErr == nil && strings.TrimSpace(result.Answer) != "" {
|
|
answerObserver(result.Answer)
|
|
}
|
|
}
|
|
} else {
|
|
result, runErr = s.weknora.AgentChat(ctx, targetSessionID, chatRequest)
|
|
}
|
|
return result, runErr
|
|
}
|
|
|
|
chat, err := runChat(sessionID)
|
|
if errors.Is(err, weknora.ErrSessionNotFound) {
|
|
if invalidator, ok := s.repository.(sessionInvalidator); ok {
|
|
if invalidateErr := invalidator.InvalidateSession(ctx, sessionKey, sessionID); invalidateErr != nil {
|
|
err = fmt.Errorf("invalidate stale weknora session: %w", invalidateErr)
|
|
} else if freshSessionID, resolveErr := s.repository.ResolveSession(ctx, mapping, createSession); resolveErr != nil {
|
|
err = fmt.Errorf("recreate weknora session: %w", resolveErr)
|
|
} else {
|
|
sessionID = freshSessionID
|
|
turn.WeKnoraSessionID = freshSessionID
|
|
chat, err = runChat(freshSessionID)
|
|
}
|
|
}
|
|
}
|
|
if err == nil && strings.TrimSpace(chat.Answer) == "" {
|
|
err = weknora.ErrEmptyAnswer
|
|
}
|
|
if err != nil {
|
|
turn.Status, turn.ErrorMessage = "failed", err.Error()
|
|
_ = s.repository.FinishTurn(ctx, turn)
|
|
return core.QAResult{}, fmt.Errorf("weknora agent chat: %w", err)
|
|
}
|
|
turn.Status, turn.Answer, turn.References = "succeeded", chat.Answer, chat.References
|
|
turn.WeKnoraRequestID = chat.RequestID
|
|
turn.KnowledgeSearched = knowledgeSearched
|
|
turn.AnswerMessageID = newID()
|
|
if err := s.repository.FinishTurn(ctx, turn); err != nil {
|
|
return core.QAResult{}, fmt.Errorf("finish turn: %w", err)
|
|
}
|
|
if s.reasoningFinalizer != nil && strings.TrimSpace(request.ReasoningExecution) != "" {
|
|
completion, completionErr := s.reasoningFinalizer.Complete(ctx, reasoningmode.TurnCompletion{
|
|
Scope: reasoningmode.Scope{
|
|
TenantKey: request.TenantKey, Channel: request.Channel, ExternalActorID: request.ExternalUserID,
|
|
},
|
|
TurnID: turn.ID, Execution: reasoningmode.ExecutionMode(request.ReasoningExecution),
|
|
Answer: turn.Answer, Locale: request.ResponseLocale, Expired: request.ReasoningExpired,
|
|
})
|
|
if completionErr != nil {
|
|
if s.reasoningErrorHandler != nil {
|
|
s.reasoningErrorHandler(completionErr)
|
|
}
|
|
} else {
|
|
displayLocale := reasoningmode.ResolveGuideLocale(request.ResponseLocale, request.Query)
|
|
turn.Answer = reasoningmode.DecorateAnswerForLocale(
|
|
turn.Answer, reasoningmode.ExecutionMode(request.ReasoningExecution),
|
|
request.ReasoningExpired, completion, displayLocale,
|
|
)
|
|
if err := s.repository.FinishTurn(ctx, turn); err != nil {
|
|
if s.reasoningErrorHandler != nil {
|
|
s.reasoningErrorHandler(err)
|
|
}
|
|
} else {
|
|
chat.Answer = turn.Answer
|
|
}
|
|
}
|
|
}
|
|
if s.observer != nil {
|
|
if obsErr := s.observer.ObserveSucceededTurn(ctx, turn); obsErr != nil && s.observerErrorHandler != nil {
|
|
s.observerErrorHandler(obsErr)
|
|
}
|
|
}
|
|
return core.QAResult{TurnID: turn.ID, AnswerMessageID: turn.AnswerMessageID,
|
|
DeliveryStatus: core.DeliveryGenerated, WeKnoraSessionID: sessionID,
|
|
WeKnoraRequestID: chat.RequestID, Answer: chat.Answer, References: chat.References}, nil
|
|
}
|
|
|
|
func isKnowledgeTool(toolName string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(toolName)) {
|
|
case "knowledge_search", "grep_chunks", "list_knowledge_chunks", "get_document_info",
|
|
"wiki_search", "wiki_read_page", "wiki_read_source_doc", "query_knowledge_graph":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func localizedQuery(query, locale string) string {
|
|
query = strings.TrimSpace(query)
|
|
switch strings.TrimSpace(locale) {
|
|
case "zh-CN":
|
|
return "请使用简体中文回答。\n\n" + query
|
|
case "en":
|
|
return "Answer in English.\n\n" + query
|
|
case "ja":
|
|
return "日本語で回答してください。\n\n" + query
|
|
case "ko":
|
|
return "한국어로 답변해 주세요.\n\n" + query
|
|
case "es":
|
|
return "Responde en español.\n\n" + query
|
|
default:
|
|
return query
|
|
}
|
|
}
|
|
|
|
func normalizeRequestIdentity(request core.QARequest) core.QARequest {
|
|
request.TenantKey = strings.TrimSpace(request.TenantKey)
|
|
request.Channel = strings.TrimSpace(request.Channel)
|
|
request.ConversationType = core.ConversationType(strings.TrimSpace(string(request.ConversationType)))
|
|
request.ExternalChatID = strings.TrimSpace(request.ExternalChatID)
|
|
request.ExternalUserID = strings.TrimSpace(request.ExternalUserID)
|
|
request.ExternalMessageID = strings.TrimSpace(request.ExternalMessageID)
|
|
request.SenderName = strings.TrimSpace(request.SenderName)
|
|
return request
|
|
}
|
|
|
|
func normalizeMessageType(request core.QARequest) string {
|
|
messageType := strings.ToLower(strings.TrimSpace(request.MessageType))
|
|
if messageType == "" {
|
|
if len(request.Images) > 0 {
|
|
return "image"
|
|
}
|
|
return "text"
|
|
}
|
|
switch messageType {
|
|
case "picture", "image":
|
|
return "image"
|
|
case "richtext", "rich_text":
|
|
return "rich_text"
|
|
case "text":
|
|
return messageType
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func validateRequest(request core.QARequest) error {
|
|
if strings.TrimSpace(request.TenantKey) == "" || strings.TrimSpace(request.Channel) == "" ||
|
|
strings.TrimSpace(request.ExternalMessageID) == "" || strings.TrimSpace(request.Query) == "" {
|
|
return errors.New("tenant_key, channel, external_message_id and query are required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newID() string {
|
|
var raw [16]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
panic("crypto/rand unavailable")
|
|
}
|
|
return hex.EncodeToString(raw[:])
|
|
}
|