Files
dobotai-platform/internal/qa/service.go
T

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[:])
}