WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginChatCompletion implements chat completion functionality
|
||||
// as a plugin that can be registered to EventManager
|
||||
type PluginChatCompletion struct {
|
||||
modelService interfaces.ModelService // Interface for model operations
|
||||
}
|
||||
|
||||
// NewPluginChatCompletion creates a new PluginChatCompletion instance
|
||||
// and registers it with the EventManager
|
||||
func NewPluginChatCompletion(eventManager *EventManager, modelService interfaces.ModelService) *PluginChatCompletion {
|
||||
res := &PluginChatCompletion{
|
||||
modelService: modelService,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginChatCompletion) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHAT_COMPLETION}
|
||||
}
|
||||
|
||||
// OnEvent handles chat completion events
|
||||
// It prepares the chat model, messages, and calls the model to generate responses
|
||||
func (p *PluginChatCompletion) OnEvent(
|
||||
ctx context.Context, eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
pipelineInfo(ctx, "Completion", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_question": chatManage.UserContent,
|
||||
"history_rounds": len(chatManage.History),
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
})
|
||||
|
||||
// Prepare chat model and options
|
||||
chatModel, opt, err := prepareChatModel(ctx, p.modelService, chatManage)
|
||||
if err != nil {
|
||||
return ErrGetChatModel.WithError(err)
|
||||
}
|
||||
|
||||
// Prepare messages including conversation history
|
||||
pipelineInfo(ctx, "Completion", "messages_ready", map[string]interface{}{
|
||||
"message_count": len(chatManage.History) + 2,
|
||||
})
|
||||
chatMessages, modelContext := prepareMessagesWithModelContext(ctx, chatManage)
|
||||
chatMessages = modelContext.EncodeMessages(chatMessages)
|
||||
ctx = withPromptCacheMetadata(ctx, chatModel, chatMessages, opt, "knowledge_qa")
|
||||
|
||||
// Call the chat model to generate response
|
||||
pipelineInfo(ctx, "Completion", "model_call", map[string]interface{}{
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
})
|
||||
chatResponse, err := chatModel.Chat(ctx, chatMessages, opt)
|
||||
if err != nil {
|
||||
pipelineError(ctx, "Completion", "model_call", map[string]interface{}{
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ErrModelCall.WithError(err)
|
||||
}
|
||||
modelContext.DecodeResponse(chatResponse)
|
||||
if orphans := modelContext.OrphanResourceHandles(chatResponse.Content); len(orphans) > 0 {
|
||||
pipelineWarn(ctx, "Completion", "orphan_resource_handles", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"handles": orphans,
|
||||
})
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Completion", "output", map[string]interface{}{
|
||||
"answer_preview": chatResponse.Content,
|
||||
"finish_reason": chatResponse.FinishReason,
|
||||
"completion_tokens": chatResponse.Usage.CompletionTokens,
|
||||
"prompt_tokens": chatResponse.Usage.PromptTokens,
|
||||
})
|
||||
chatManage.ChatResponse = chatResponse
|
||||
return next()
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// PluginChatCompletionStream implements streaming chat completion functionality
|
||||
// as a plugin that can be registered to EventManager
|
||||
type PluginChatCompletionStream struct {
|
||||
modelService interfaces.ModelService // Interface for model operations
|
||||
}
|
||||
|
||||
// NewPluginChatCompletionStream creates a new PluginChatCompletionStream instance
|
||||
// and registers it with the EventManager
|
||||
func NewPluginChatCompletionStream(eventManager *EventManager,
|
||||
modelService interfaces.ModelService,
|
||||
) *PluginChatCompletionStream {
|
||||
res := &PluginChatCompletionStream{
|
||||
modelService: modelService,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginChatCompletionStream) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHAT_COMPLETION_STREAM}
|
||||
}
|
||||
|
||||
// OnEvent handles streaming chat completion events
|
||||
// It prepares the chat model, messages, and initiates streaming response
|
||||
func (p *PluginChatCompletionStream) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
pipelineInfo(ctx, "Stream", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_question": chatManage.UserContent,
|
||||
"history_rounds": len(chatManage.History),
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
})
|
||||
|
||||
// Prepare chat model and options
|
||||
chatModel, opt, err := prepareChatModel(ctx, p.modelService, chatManage)
|
||||
if err != nil {
|
||||
return ErrGetChatModel.WithError(err)
|
||||
}
|
||||
|
||||
// Prepare base messages without history
|
||||
|
||||
chatMessages, modelContext := prepareMessagesWithModelContext(ctx, chatManage)
|
||||
chatMessages = modelContext.EncodeMessages(chatMessages)
|
||||
ctx = withPromptCacheMetadata(ctx, chatModel, chatMessages, opt, "knowledge_qa")
|
||||
pipelineInfo(ctx, "Stream", "messages_ready", map[string]interface{}{
|
||||
"message_count": len(chatMessages),
|
||||
"system_prompt": chatMessages[0].Content,
|
||||
})
|
||||
pipelineInfo(ctx, "Stream", "user_message", map[string]interface{}{
|
||||
"content": chatMessages[len(chatMessages)-1].Content,
|
||||
})
|
||||
// EventBus is required for event-driven streaming
|
||||
if chatManage.EventBus == nil {
|
||||
pipelineError(ctx, "Stream", "eventbus_missing", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
return ErrModelCall.WithError(errors.New("EventBus is required for streaming"))
|
||||
}
|
||||
eventBus := chatManage.EventBus
|
||||
|
||||
pipelineInfo(ctx, "Stream", "eventbus_ready", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
|
||||
// Initiate streaming chat model call with independent context
|
||||
pipelineInfo(ctx, "Stream", "model_call", map[string]interface{}{
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
})
|
||||
responseChan, err := chatModel.ChatStream(ctx, chatMessages, opt)
|
||||
if err != nil {
|
||||
pipelineError(ctx, "Stream", "model_call", map[string]interface{}{
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ErrModelCall.WithError(err)
|
||||
}
|
||||
if responseChan == nil {
|
||||
pipelineError(ctx, "Stream", "model_call", map[string]interface{}{
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
"error": "nil_channel",
|
||||
})
|
||||
return ErrModelCall.WithError(errors.New("chat stream returned nil channel"))
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Stream", "model_started", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
|
||||
// Start goroutine to consume channel and emit events directly.
|
||||
// reasoning_content is routed to EventAgentThought (SSE response_type=thinking)
|
||||
// and plain answer text to EventAgentFinalAnswer, matching the Agent pipeline.
|
||||
// The goroutine monitors ctx.Done() to avoid leaking when the context is cancelled
|
||||
// and the upstream channel is not closed promptly.
|
||||
go func() {
|
||||
answerDecoder := modelContext.StreamDecoder()
|
||||
thinkingDecoder := modelContext.StreamDecoder()
|
||||
thinkingID := fmt.Sprintf("%s-thinking", uuid.New().String()[:8])
|
||||
answerID := fmt.Sprintf("%s-answer", uuid.New().String()[:8])
|
||||
thinkingOpen := false
|
||||
answerCompleted := false
|
||||
|
||||
closeThinking := func() {
|
||||
if !thinkingOpen {
|
||||
return
|
||||
}
|
||||
eventBus.Emit(ctx, types.Event{
|
||||
ID: thinkingID,
|
||||
Type: types.EventType(event.EventAgentThought),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentThoughtData{
|
||||
Done: true,
|
||||
},
|
||||
})
|
||||
thinkingOpen = false
|
||||
}
|
||||
|
||||
// flushDecoders drains any handle suffix the stream decoders held back to
|
||||
// bridge references split across provider chunks. Both the normal close
|
||||
// and the cancellation path must call this, otherwise a resource
|
||||
// reference in flight at teardown is silently dropped (and never
|
||||
// persisted, since the assistant message is saved from these events).
|
||||
flushDecoders := func() {
|
||||
thinkingTail := thinkingDecoder.Flush()
|
||||
if thinkingTail != "" {
|
||||
_ = eventBus.Emit(ctx, types.Event{
|
||||
ID: thinkingID,
|
||||
Type: types.EventType(event.EventAgentThought),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentThoughtData{Content: thinkingTail},
|
||||
})
|
||||
}
|
||||
answerTail := answerDecoder.Flush()
|
||||
if answerTail != "" {
|
||||
_ = eventBus.Emit(ctx, types.Event{
|
||||
ID: answerID,
|
||||
Type: types.EventType(event.EventAgentFinalAnswer),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentFinalAnswerData{Content: answerTail},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
flushDecoders()
|
||||
closeThinking()
|
||||
pipelineInfo(ctx, "Stream", "context_cancelled", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
return
|
||||
|
||||
case response, ok := <-responseChan:
|
||||
if !ok {
|
||||
flushDecoders()
|
||||
closeThinking()
|
||||
pipelineInfo(ctx, "Stream", "channel_close", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if response.ResponseType == types.ResponseTypeError {
|
||||
pipelineError(ctx, "Stream", "stream_error", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"error": response.Content,
|
||||
})
|
||||
eventBus.Emit(ctx, types.Event{
|
||||
ID: fmt.Sprintf("%s-error", uuid.New().String()[:8]),
|
||||
Type: types.EventType(event.EventError),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.ErrorData{
|
||||
Error: response.Content,
|
||||
Stage: "chat_completion_stream",
|
||||
SessionID: chatManage.SessionID,
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if response.ResponseType == types.ResponseTypeThinking {
|
||||
response.Content = thinkingDecoder.Feed(response.Content)
|
||||
if response.Done {
|
||||
response.Content += thinkingDecoder.Flush()
|
||||
}
|
||||
if response.Content != "" {
|
||||
thinkingOpen = true
|
||||
eventBus.Emit(ctx, types.Event{
|
||||
ID: thinkingID,
|
||||
Type: types.EventType(event.EventAgentThought),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentThoughtData{
|
||||
Content: response.Content,
|
||||
Done: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
if response.Done {
|
||||
closeThinking()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if response.ResponseType == types.ResponseTypeAnswer {
|
||||
// Providers can emit a completion once for finish_reason and again
|
||||
// for their EOF sentinel. A final answer is a terminal event for a
|
||||
// single stream, so forwarding a later duplicate would put an answer
|
||||
// after the session's complete event.
|
||||
if answerCompleted {
|
||||
continue
|
||||
}
|
||||
response.Content = answerDecoder.Feed(response.Content)
|
||||
if response.Done {
|
||||
response.Content += answerDecoder.Flush()
|
||||
answerCompleted = true
|
||||
}
|
||||
closeThinking()
|
||||
eventBus.Emit(ctx, types.Event{
|
||||
ID: answerID,
|
||||
Type: types.EventType(event.EventAgentFinalAnswer),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentFinalAnswerData{
|
||||
Content: response.Content,
|
||||
Done: response.Done,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return next()
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// syncEventBus is a thread-safe recorder; the stream plugin emits from a
|
||||
// background goroutine so the test must guard concurrent appends.
|
||||
type syncEventBus struct {
|
||||
mu sync.Mutex
|
||||
events []types.Event
|
||||
}
|
||||
|
||||
func (b *syncEventBus) On(types.EventType, types.EventHandler) {}
|
||||
|
||||
func (b *syncEventBus) Emit(_ context.Context, evt types.Event) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.events = append(b.events, evt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *syncEventBus) finalAnswerContents() []string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
var out []string
|
||||
for _, evt := range b.events {
|
||||
if evt.Type != types.EventType(event.EventAgentFinalAnswer) {
|
||||
continue
|
||||
}
|
||||
if data, ok := evt.Data.(event.AgentFinalAnswerData); ok {
|
||||
out = append(out, data.Content)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// openStreamChat returns a buffered channel preloaded with chunks and never
|
||||
// closes it, so the stream plugin blocks on the channel until ctx is cancelled
|
||||
// — deterministically exercising the ctx.Done() branch.
|
||||
type openStreamChat struct {
|
||||
chunks []types.StreamResponse
|
||||
closeStream bool
|
||||
}
|
||||
|
||||
func (m *openStreamChat) Chat(context.Context, []chat.Message, *chat.ChatOptions) (*types.ChatResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *openStreamChat) ChatStream(
|
||||
context.Context, []chat.Message, *chat.ChatOptions,
|
||||
) (<-chan types.StreamResponse, error) {
|
||||
ch := make(chan types.StreamResponse, len(m.chunks))
|
||||
for _, c := range m.chunks {
|
||||
ch <- c
|
||||
}
|
||||
if m.closeStream {
|
||||
close(ch)
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (m *openStreamChat) GetModelName() string { return "mock" }
|
||||
func (m *openStreamChat) GetModelID() string { return "mock" }
|
||||
|
||||
// stubModelService only needs GetChatModel; the rest is unused for this test.
|
||||
type stubModelService struct {
|
||||
interfaces.ModelService
|
||||
model chat.Chat
|
||||
}
|
||||
|
||||
func (s *stubModelService) GetChatModel(context.Context, string) (chat.Chat, error) {
|
||||
return s.model, nil
|
||||
}
|
||||
|
||||
// TestStreamDropsIncompleteHandleOnCancel verifies that cancellation cannot
|
||||
// leak a model-context protocol fragment to the client.
|
||||
func TestStreamDropsIncompleteHandleOnCancel(t *testing.T) {
|
||||
const ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
|
||||
bus := &syncEventBus{}
|
||||
model := &openStreamChat{chunks: []types.StreamResponse{
|
||||
// Ends with a partial alias prefix ("res://0"), so the stream decoder
|
||||
// holds it back waiting for the rest that never arrives before cancel.
|
||||
{ResponseType: types.ResponseTypeAnswer, Content: "hello res://0"},
|
||||
}}
|
||||
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.SessionID = "sess-cancel"
|
||||
chatManage.UserContent = ref // seeds the registry so res://0001 becomes a known alias
|
||||
chatManage.EventBus = bus
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
plugin := &PluginChatCompletionStream{modelService: &stubModelService{model: model}}
|
||||
require.Nil(t, plugin.OnEvent(ctx, types.CHAT_COMPLETION_STREAM, chatManage, func() *PluginError { return nil }))
|
||||
|
||||
// Wait until the pre-hold content has been emitted, then cancel.
|
||||
require.Eventually(t, func() bool {
|
||||
for _, c := range bus.finalAnswerContents() {
|
||||
if c == "hello " {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 2*time.Second, 5*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
|
||||
// Give the cancellation path time to flush, then assert that only the
|
||||
// meaningful prefix was emitted; the incomplete private handle is dropped.
|
||||
require.Eventually(t, func() bool { return len(bus.finalAnswerContents()) >= 1 }, 2*time.Second, 5*time.Millisecond)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
require.Equal(t, []string{"hello "}, bus.finalAnswerContents())
|
||||
}
|
||||
|
||||
func TestStreamIgnoresDuplicateTerminalAnswer(t *testing.T) {
|
||||
bus := &syncEventBus{}
|
||||
model := &openStreamChat{closeStream: true, chunks: []types.StreamResponse{
|
||||
{ResponseType: types.ResponseTypeAnswer, Content: "hello"},
|
||||
{ResponseType: types.ResponseTypeAnswer, Done: true},
|
||||
// Some providers repeat the same terminal response when their EOF
|
||||
// sentinel is received after finish_reason.
|
||||
{ResponseType: types.ResponseTypeAnswer, Done: true},
|
||||
}}
|
||||
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.SessionID = "sess-duplicate-done"
|
||||
chatManage.EventBus = bus
|
||||
plugin := &PluginChatCompletionStream{modelService: &stubModelService{model: model}}
|
||||
require.Nil(t, plugin.OnEvent(context.Background(), types.CHAT_COMPLETION_STREAM, chatManage, func() *PluginError { return nil }))
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
bus.mu.Lock()
|
||||
defer bus.mu.Unlock()
|
||||
return len(bus.events) == 2
|
||||
}, 2*time.Second, 5*time.Millisecond)
|
||||
|
||||
bus.mu.Lock()
|
||||
defer bus.mu.Unlock()
|
||||
var answerEvents []event.AgentFinalAnswerData
|
||||
for _, evt := range bus.events {
|
||||
if evt.Type == types.EventType(event.EventAgentFinalAnswer) {
|
||||
answerEvents = append(answerEvents, evt.Data.(event.AgentFinalAnswerData))
|
||||
}
|
||||
}
|
||||
require.Equal(t, []event.AgentFinalAnswerData{{Content: "hello"}, {Done: true}}, answerEvents)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// Plugin defines the interface for chat pipeline plugins
|
||||
// Plugins can handle specific events in the chat pipeline
|
||||
type Plugin interface {
|
||||
// OnEvent handles the event with given context and chat management object
|
||||
OnEvent(
|
||||
ctx context.Context,
|
||||
eventType types.EventType,
|
||||
chatManage *types.ChatManage,
|
||||
next func() *PluginError,
|
||||
) *PluginError
|
||||
// ActivationEvents returns the event types this plugin can handle
|
||||
ActivationEvents() []types.EventType
|
||||
}
|
||||
|
||||
// EventManager manages plugins and their event handling
|
||||
type EventManager struct {
|
||||
// Map of event types to registered plugins
|
||||
listeners map[types.EventType][]Plugin
|
||||
// Map of event types to handler functions
|
||||
handlers map[types.EventType]func(context.Context, types.EventType, *types.ChatManage) *PluginError
|
||||
}
|
||||
|
||||
// NewEventManager creates and initializes a new EventManager
|
||||
func NewEventManager() *EventManager {
|
||||
return &EventManager{
|
||||
listeners: make(map[types.EventType][]Plugin),
|
||||
handlers: make(map[types.EventType]func(context.Context, types.EventType, *types.ChatManage) *PluginError),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a plugin to the EventManager and sets up its event handlers
|
||||
func (e *EventManager) Register(plugin Plugin) {
|
||||
if e.listeners == nil {
|
||||
e.listeners = make(map[types.EventType][]Plugin)
|
||||
}
|
||||
if e.handlers == nil {
|
||||
e.handlers = make(map[types.EventType]func(context.Context, types.EventType, *types.ChatManage) *PluginError)
|
||||
}
|
||||
for _, eventType := range plugin.ActivationEvents() {
|
||||
e.listeners[eventType] = append(e.listeners[eventType], plugin)
|
||||
e.handlers[eventType] = e.buildHandler(e.listeners[eventType])
|
||||
}
|
||||
}
|
||||
|
||||
// buildHandler constructs a handler chain for the given plugins
|
||||
func (e *EventManager) buildHandler(plugins []Plugin) func(
|
||||
ctx context.Context, eventType types.EventType, chatManage *types.ChatManage,
|
||||
) *PluginError {
|
||||
next := func(context.Context, types.EventType, *types.ChatManage) *PluginError { return nil }
|
||||
for i := len(plugins) - 1; i >= 0; i-- {
|
||||
current := plugins[i]
|
||||
prevNext := next
|
||||
next = func(ctx context.Context, eventType types.EventType, chatManage *types.ChatManage) *PluginError {
|
||||
return current.OnEvent(ctx, eventType, chatManage, func() *PluginError {
|
||||
return prevNext(ctx, eventType, chatManage)
|
||||
})
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// Trigger invokes the handler for the specified event type
|
||||
func (e *EventManager) Trigger(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage,
|
||||
) *PluginError {
|
||||
if handler, ok := e.handlers[eventType]; ok {
|
||||
return handler(ctx, eventType, chatManage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PluginError represents an error in plugin execution
|
||||
type PluginError struct {
|
||||
Err error // Original error
|
||||
Description string // Human-readable description
|
||||
ErrorType string // Error type identifier
|
||||
}
|
||||
|
||||
// Predefined plugin errors
|
||||
var (
|
||||
ErrSearchNothing = &PluginError{
|
||||
Description: "No relevant content found",
|
||||
ErrorType: "search_nothing",
|
||||
}
|
||||
ErrSearch = &PluginError{
|
||||
Description: "Failed to search knowledge base",
|
||||
ErrorType: "search_failed",
|
||||
}
|
||||
ErrRerank = &PluginError{
|
||||
Description: "Reranking failed",
|
||||
ErrorType: "rerank_failed",
|
||||
}
|
||||
ErrGetRerankModel = &PluginError{
|
||||
Description: "Failed to get rerank model",
|
||||
ErrorType: "get_rerank_model_failed",
|
||||
}
|
||||
ErrGetChatModel = &PluginError{
|
||||
Description: "Failed to get chat model",
|
||||
ErrorType: "get_chat_model_failed",
|
||||
}
|
||||
ErrTemplateParse = &PluginError{
|
||||
Description: "Failed to parse context template",
|
||||
ErrorType: "template_parse_failed",
|
||||
}
|
||||
ErrTemplateExecute = &PluginError{
|
||||
Description: "Failed to generate search content",
|
||||
ErrorType: "template_execution_failed",
|
||||
}
|
||||
ErrModelCall = &PluginError{
|
||||
Description: "Failed to call model",
|
||||
ErrorType: "model_call_failed",
|
||||
}
|
||||
ErrGetHistory = &PluginError{
|
||||
Description: "Failed to get conversation history",
|
||||
ErrorType: "get_history_failed",
|
||||
}
|
||||
)
|
||||
|
||||
// clone creates a copy of the PluginError
|
||||
func (p *PluginError) clone() *PluginError {
|
||||
return &PluginError{
|
||||
Description: p.Description,
|
||||
ErrorType: p.ErrorType,
|
||||
}
|
||||
}
|
||||
|
||||
// WithError attaches an error to the PluginError and returns a new instance
|
||||
func (p *PluginError) WithError(err error) *PluginError {
|
||||
pp := p.clone()
|
||||
pp.Err = err
|
||||
return pp
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// Define a test Plugin implementation
|
||||
type testPlugin struct {
|
||||
name string
|
||||
events []types.EventType
|
||||
shouldError bool
|
||||
errorToReturn *PluginError
|
||||
}
|
||||
|
||||
func (p *testPlugin) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
if p.shouldError {
|
||||
return p.errorToReturn
|
||||
}
|
||||
fmt.Printf("Plugin %s triggered\n", p.name)
|
||||
err := next()
|
||||
fmt.Printf("Plugin %s finished\n", p.name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *testPlugin) ActivationEvents() []types.EventType {
|
||||
return p.events
|
||||
}
|
||||
|
||||
func TestTrigger(t *testing.T) {
|
||||
// Prepare test data
|
||||
ctx := context.Background()
|
||||
chatManage := &types.ChatManage{}
|
||||
testEvent := types.EventType("test_event")
|
||||
|
||||
// Test scenario 1: No plugins registered
|
||||
t.Run("NoPluginsRegistered", func(t *testing.T) {
|
||||
manager := &EventManager{}
|
||||
err := manager.Trigger(ctx, testEvent, chatManage)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 2: Register a normally working plugin
|
||||
t.Run("SinglePluginSuccess", func(t *testing.T) {
|
||||
manager := &EventManager{}
|
||||
plugin := &testPlugin{
|
||||
name: "test_plugin",
|
||||
events: []types.EventType{testEvent},
|
||||
}
|
||||
manager.Register(plugin)
|
||||
|
||||
err := manager.Trigger(ctx, testEvent, chatManage)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 3: Plugin chain call
|
||||
t.Run("PluginChain", func(t *testing.T) {
|
||||
manager := &EventManager{}
|
||||
plugin1 := &testPlugin{
|
||||
name: "plugin1",
|
||||
events: []types.EventType{testEvent},
|
||||
}
|
||||
plugin2 := &testPlugin{
|
||||
name: "plugin2",
|
||||
events: []types.EventType{testEvent},
|
||||
}
|
||||
manager.Register(plugin1)
|
||||
manager.Register(plugin2)
|
||||
|
||||
err := manager.Trigger(ctx, testEvent, chatManage)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 4: Plugin returns error
|
||||
t.Run("PluginReturnsError", func(t *testing.T) {
|
||||
manager := &EventManager{}
|
||||
expectedErr := &PluginError{Description: "test error"}
|
||||
plugin := &testPlugin{
|
||||
name: "error_plugin",
|
||||
events: []types.EventType{testEvent},
|
||||
shouldError: true,
|
||||
errorToReturn: expectedErr,
|
||||
}
|
||||
manager.Register(plugin)
|
||||
|
||||
err := manager.Trigger(ctx, testEvent, chatManage)
|
||||
if err != expectedErr {
|
||||
t.Errorf("Expected error %v, got %v", expectedErr, err)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 5: A plugin in the chain returns error
|
||||
t.Run("ErrorInPluginChain", func(t *testing.T) {
|
||||
manager := &EventManager{}
|
||||
expectedErr := &PluginError{Description: "test error"}
|
||||
plugin1 := &testPlugin{
|
||||
name: "plugin1",
|
||||
events: []types.EventType{testEvent},
|
||||
}
|
||||
plugin2 := &testPlugin{
|
||||
name: "plugin2",
|
||||
events: []types.EventType{testEvent},
|
||||
shouldError: true,
|
||||
errorToReturn: expectedErr,
|
||||
}
|
||||
manager.Register(plugin1)
|
||||
manager.Register(plugin2)
|
||||
|
||||
err := manager.Trigger(ctx, testEvent, chatManage)
|
||||
if err != expectedErr {
|
||||
t.Errorf("Expected error %v, got %v", expectedErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/common"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
var regThinkTags = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
||||
|
||||
const retrievedImageOutputRequirement = `
|
||||
|
||||
## Retrieved Image Output Requirement
|
||||
The retrieved context for this turn contains Markdown images. Images attached to retrieved passages should be treated as relevant by default.
|
||||
- Unless the user explicitly requests text-only output, or every retrieved image is clearly unrelated to the answer, the final answer MUST include at least one relevant Markdown image copied from the retrieved context.
|
||||
- Copy the complete Markdown image syntax and its URL verbatim. Never invent, shorten, normalize, or replace the URL.
|
||||
- Use ASCII half-width parentheses in image Markdown exactly as . Never use full-width ( or ).
|
||||
- Place each image immediately after the paragraph it supports, rather than collecting images at the end.
|
||||
- When multiple retrieved images support different sections of a multi-section answer, include them in their corresponding sections instead of stopping after the first image.
|
||||
- Before finishing, silently verify that the answer contains a Markdown image whenever this requirement applies.`
|
||||
|
||||
func appendRetrievedImageOutputRequirement(systemPrompt, renderedContexts string) string {
|
||||
if !searchutil.MarkdownImageRegex.MatchString(renderedContexts) {
|
||||
return systemPrompt
|
||||
}
|
||||
return strings.TrimRight(systemPrompt, " \t\r\n") + retrievedImageOutputRequirement
|
||||
}
|
||||
|
||||
// pipelineInfo logs pipeline info level entries.
|
||||
func pipelineInfo(ctx context.Context, stage, action string, fields map[string]interface{}) {
|
||||
common.PipelineInfo(ctx, stage, action, fields)
|
||||
}
|
||||
|
||||
// pipelineWarn logs pipeline warning level entries.
|
||||
func pipelineWarn(ctx context.Context, stage, action string, fields map[string]interface{}) {
|
||||
common.PipelineWarn(ctx, stage, action, fields)
|
||||
}
|
||||
|
||||
// pipelineError logs pipeline error level entries.
|
||||
func pipelineError(ctx context.Context, stage, action string, fields map[string]interface{}) {
|
||||
common.PipelineError(ctx, stage, action, fields)
|
||||
}
|
||||
|
||||
// prepareChatModel shared logic to prepare chat model and options
|
||||
// it gets the chat model and sets up the chat options based on the chat manage.
|
||||
func prepareChatModel(ctx context.Context, modelService interfaces.ModelService,
|
||||
chatManage *types.ChatManage,
|
||||
) (chat.Chat, *chat.ChatOptions, error) {
|
||||
chatModel, err := modelService.GetChatModel(ctx, chatManage.ChatModelID)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to get chat model: %v", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
opt := &chat.ChatOptions{
|
||||
Temperature: chatManage.SummaryConfig.Temperature,
|
||||
TopP: chatManage.SummaryConfig.TopP,
|
||||
Seed: chatManage.SummaryConfig.Seed,
|
||||
MaxTokens: chatManage.SummaryConfig.MaxTokens,
|
||||
MaxCompletionTokens: chatManage.SummaryConfig.MaxCompletionTokens,
|
||||
FrequencyPenalty: chatManage.SummaryConfig.FrequencyPenalty,
|
||||
PresencePenalty: chatManage.SummaryConfig.PresencePenalty,
|
||||
Thinking: chatManage.SummaryConfig.Thinking,
|
||||
PromptCacheKey: chatManage.SessionID,
|
||||
}
|
||||
if opt.Thinking != nil {
|
||||
pipelineInfo(ctx, "Stream", "thinking_option", map[string]interface{}{
|
||||
"enabled": *opt.Thinking,
|
||||
})
|
||||
}
|
||||
|
||||
return chatModel, opt, nil
|
||||
}
|
||||
|
||||
// prepareMessagesWithHistory prepare complete messages including history.
|
||||
// When SystemPromptOverride is set (e.g. by intent-specific prompt logic),
|
||||
// it takes precedence over the default SummaryConfig.Prompt.
|
||||
func prepareMessagesWithHistory(chatManage *types.ChatManage) []chat.Message {
|
||||
base := chatManage.SummaryConfig.Prompt
|
||||
if chatManage.SystemPromptOverride != "" {
|
||||
base = chatManage.SystemPromptOverride
|
||||
}
|
||||
systemPrompt := types.RenderPromptPlaceholders(base, types.PlaceholderValues{
|
||||
"query": chatManage.Query,
|
||||
"language": chatManage.Language,
|
||||
"contexts": chatManage.RenderedContexts,
|
||||
})
|
||||
// Memory goes at the end of the system prompt, after the retrieved-context
|
||||
// placeholders have been rendered, so a remembered sentence can never be
|
||||
// substituted into prompt structure.
|
||||
systemPrompt += chatManage.MemoryPrompt
|
||||
|
||||
chatMessages := []chat.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
}
|
||||
|
||||
chatMessages = AppendHistoryMessages(chatMessages, chatManage.History)
|
||||
|
||||
// Image-output rules are turn-specific. Putting them on the current user
|
||||
// message keeps the system prefix byte-stable so provider prompt caches
|
||||
// still hit on later turns of the same session.
|
||||
userContent := appendRetrievedImageOutputRequirement(chatManage.UserContent, chatManage.RenderedContexts)
|
||||
|
||||
// Add current user message. Only include images when the chat model supports
|
||||
// vision; non-vision models rely on the text description in UserContent.
|
||||
userMsg := chat.Message{Role: "user", Content: userContent}
|
||||
if chatManage.ChatModelSupportsVision && len(chatManage.Images) > 0 {
|
||||
userMsg.Images = chatManage.Images
|
||||
}
|
||||
chatMessages = append(chatMessages, userMsg)
|
||||
|
||||
return chatMessages
|
||||
}
|
||||
|
||||
func withPromptCacheMetadata(
|
||||
ctx context.Context,
|
||||
chatModel chat.Chat,
|
||||
messages []chat.Message,
|
||||
opts *chat.ChatOptions,
|
||||
purpose string,
|
||||
) context.Context {
|
||||
prefixFingerprint := chat.PromptPrefixFingerprint(messages, opts)
|
||||
_ = chatModel // model identity is already captured by the usage sink
|
||||
return types.WithLLMCallMetadata(ctx, purpose, prefixFingerprint)
|
||||
}
|
||||
|
||||
// AppendHistoryMessages appends prior Q&A rounds in chronological order.
|
||||
// History is already filtered and truncated upstream by the load_history plugin.
|
||||
func AppendHistoryMessages(messages []chat.Message, history []*types.History) []chat.Message {
|
||||
for _, history := range history {
|
||||
messages = append(messages, chat.Message{Role: "user", Content: history.Query})
|
||||
messages = append(messages, chat.Message{Role: "assistant", Content: history.Answer})
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// loadAndProcessHistory fetches recent messages, groups them into Q&A pairs,
|
||||
// strips <think> tags from assistant answers, sorts by recency, and limits to maxRounds.
|
||||
// fetchCount controls how many raw messages to fetch (typically maxRounds*2+10).
|
||||
func loadAndProcessHistory(
|
||||
ctx context.Context,
|
||||
messageService interfaces.MessageService,
|
||||
sessionID string,
|
||||
maxRounds int,
|
||||
fetchCount int,
|
||||
) ([]*types.History, error) {
|
||||
history, err := messageService.GetRecentMessagesBySession(ctx, sessionID, fetchCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
historyMap := make(map[string]*types.History)
|
||||
for _, message := range history {
|
||||
h, ok := historyMap[message.RequestID]
|
||||
if !ok {
|
||||
h = &types.History{}
|
||||
}
|
||||
if message.Role == "user" {
|
||||
// RenderedContent is a snapshot of the prompt/context format used by
|
||||
// the original turn. Replaying it would mix legacy <context id="…">
|
||||
// envelopes and old citation instructions into the current protocol.
|
||||
// Historical references are carried separately in KnowledgeReferences
|
||||
// and can be re-merged into this turn's freshly rendered context.
|
||||
h.Query = message.Content
|
||||
h.CreateAt = message.CreatedAt
|
||||
if desc := extractImageCaptions(message.Images); desc != "" {
|
||||
h.Query += "\n\n[用户上传图片内容]\n" + desc
|
||||
}
|
||||
if len(message.Attachments) > 0 {
|
||||
h.Query += message.Attachments.BuildPrompt()
|
||||
}
|
||||
} else {
|
||||
h.Answer = regThinkTags.ReplaceAllString(message.Content, "")
|
||||
h.KnowledgeReferences = message.KnowledgeReferences
|
||||
}
|
||||
historyMap[message.RequestID] = h
|
||||
}
|
||||
|
||||
historyList := make([]*types.History, 0, len(historyMap))
|
||||
for _, h := range historyMap {
|
||||
if h.Answer != "" && h.Query != "" {
|
||||
historyList = append(historyList, h)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(historyList, func(i, j int) bool {
|
||||
return historyList[i].CreateAt.After(historyList[j].CreateAt)
|
||||
})
|
||||
|
||||
if len(historyList) > maxRounds {
|
||||
historyList = historyList[:maxRounds]
|
||||
}
|
||||
|
||||
slices.Reverse(historyList)
|
||||
return historyList, nil
|
||||
}
|
||||
|
||||
// extractImageCaptions concatenates non-empty Caption fields from stored
|
||||
// message images. Used when loading history so that previous turns' image
|
||||
// descriptions are visible to the model.
|
||||
func extractImageCaptions(images types.MessageImages) string {
|
||||
var parts []string
|
||||
for _, img := range images {
|
||||
if img.Caption != "" {
|
||||
parts = append(parts, img.Caption)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concurrency utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ParallelTask represents a named unit of concurrent work.
|
||||
type ParallelTask struct {
|
||||
Name string
|
||||
Run func() *PluginError
|
||||
}
|
||||
|
||||
// RunParallel executes tasks concurrently.
|
||||
// Returns a map of task name → error for tasks that returned non-nil errors.
|
||||
func RunParallel(tasks ...ParallelTask) map[string]*PluginError {
|
||||
errs := make(map[string]*PluginError)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(len(tasks))
|
||||
for _, task := range tasks {
|
||||
go func(t ParallelTask) {
|
||||
defer wg.Done()
|
||||
if err := t.Run(); err != nil {
|
||||
mu.Lock()
|
||||
errs[t.Name] = err
|
||||
mu.Unlock()
|
||||
}
|
||||
}(task)
|
||||
}
|
||||
wg.Wait()
|
||||
return errs
|
||||
}
|
||||
|
||||
// ParallelMap applies fn to each element of items concurrently (up to
|
||||
// maxWorkers goroutines) and returns results in the same order as items.
|
||||
// If maxWorkers <= 0, concurrency is unbounded (one goroutine per item).
|
||||
func ParallelMap[T, R any](items []T, maxWorkers int, fn func(int, T) R) []R {
|
||||
n := len(items)
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
results := make([]R, n)
|
||||
|
||||
if maxWorkers <= 0 || maxWorkers > n {
|
||||
maxWorkers = n
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, maxWorkers)
|
||||
|
||||
for i, item := range items {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(idx int, it T) {
|
||||
defer func() { <-sem; wg.Done() }()
|
||||
results[idx] = fn(idx, it)
|
||||
}(i, item)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppendRetrievedImageOutputRequirement(t *testing.T) {
|
||||
base := "Answer from retrieved evidence."
|
||||
withImage := appendRetrievedImageOutputRequirement(
|
||||
base,
|
||||
"context\n",
|
||||
)
|
||||
for _, required := range []string{
|
||||
base,
|
||||
"MUST include at least one relevant Markdown image",
|
||||
"Copy the complete Markdown image syntax and its URL verbatim",
|
||||
"ASCII half-width parentheses",
|
||||
"immediately after the paragraph it supports",
|
||||
} {
|
||||
if !strings.Contains(withImage, required) {
|
||||
t.Fatalf("expected %q in dynamic image requirement:\n%s", required, withImage)
|
||||
}
|
||||
}
|
||||
|
||||
withoutImage := appendRetrievedImageOutputRequirement(base, "text-only retrieved context")
|
||||
if withoutImage != base {
|
||||
t.Fatalf("text-only context should not change the user turn: %q", withoutImage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
type PluginDataAnalysis struct {
|
||||
modelService interfaces.ModelService
|
||||
knowledgeBaseService interfaces.KnowledgeBaseService
|
||||
knowledgeService interfaces.KnowledgeService
|
||||
fileService interfaces.FileService
|
||||
chunkRepo interfaces.ChunkRepository
|
||||
tenantService interfaces.TenantService
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPluginDataAnalysis(
|
||||
eventManager *EventManager,
|
||||
modelService interfaces.ModelService,
|
||||
knowledgeBaseService interfaces.KnowledgeBaseService,
|
||||
knowledgeService interfaces.KnowledgeService,
|
||||
fileService interfaces.FileService,
|
||||
chunkRepo interfaces.ChunkRepository,
|
||||
tenantService interfaces.TenantService,
|
||||
db *sql.DB,
|
||||
) *PluginDataAnalysis {
|
||||
p := &PluginDataAnalysis{
|
||||
modelService: modelService,
|
||||
knowledgeBaseService: knowledgeBaseService,
|
||||
knowledgeService: knowledgeService,
|
||||
fileService: fileService,
|
||||
chunkRepo: chunkRepo,
|
||||
tenantService: tenantService,
|
||||
db: db,
|
||||
}
|
||||
eventManager.Register(p)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *PluginDataAnalysis) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.DATA_ANALYSIS}
|
||||
}
|
||||
|
||||
func (p *PluginDataAnalysis) OnEvent(
|
||||
ctx context.Context,
|
||||
eventType types.EventType,
|
||||
chatManage *types.ChatManage,
|
||||
next func() *PluginError,
|
||||
) *PluginError {
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
return next()
|
||||
}
|
||||
// 1. Check if there are any CSV/Excel files in MergeResult
|
||||
var dataFiles []*types.SearchResult
|
||||
for _, result := range chatManage.MergeResult {
|
||||
if isDataFile(result.KnowledgeFilename) {
|
||||
dataFiles = append(dataFiles, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out table column and table summary chunks from MergeResult
|
||||
chatManage.MergeResult = filterOutTableChunks(chatManage.MergeResult)
|
||||
|
||||
if len(dataFiles) == 0 {
|
||||
return next()
|
||||
}
|
||||
|
||||
// 2. Ask LLM if data analysis is needed
|
||||
// We only process the first data file for now to avoid complexity
|
||||
targetFile := dataFiles[0]
|
||||
|
||||
// Get Knowledge details to get file path
|
||||
knowledge, err := p.knowledgeService.GetKnowledgeByID(ctx, targetFile.KnowledgeID)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to get knowledge %s: %v", targetFile.KnowledgeID, err)
|
||||
return next()
|
||||
}
|
||||
|
||||
// Initialize DataAnalysisTool
|
||||
tool := tools.NewDataAnalysisTool(p.knowledgeBaseService, p.knowledgeService, p.tenantService, p.fileService, p.db, chatManage.SessionID)
|
||||
defer tool.Cleanup(ctx)
|
||||
|
||||
// Load data into DuckDB
|
||||
schema, err := tool.LoadFromKnowledge(ctx, knowledge)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to get data schema: %v", err)
|
||||
return next()
|
||||
}
|
||||
|
||||
// Ask LLM to generate SQL for data analysis
|
||||
chatModel, err := p.modelService.GetChatModel(ctx, chatManage.ChatModelID)
|
||||
if err != nil {
|
||||
return ErrGetChatModel.WithError(err)
|
||||
}
|
||||
|
||||
// Use utils.GenerateSchema to generate format schema for DataAnalysisInput
|
||||
formatSchema := utils.GenerateSchema[tools.DataAnalysisInput]()
|
||||
|
||||
analysisPrompt := fmt.Sprintf(`
|
||||
User Question: %s
|
||||
Knowledge ID: %s
|
||||
Table Schema: %s
|
||||
|
||||
Determine if the user's question requires data analysis (e.g., statistics, aggregation, filtering) on this table.
|
||||
If YES, generate a DuckDB SQL query to answer the user's question and fill in the knowledge_id and sql fields.
|
||||
If NO, leave the sql field empty.
|
||||
|
||||
Return your response in the specified JSON format.`, chatManage.Query, knowledge.ID, schema.Description())
|
||||
|
||||
modelCtx := types.WithLLMCallMetadata(ctx, "data_analysis_plan", "")
|
||||
response, err := chatModel.Chat(modelCtx, []chat.Message{
|
||||
{Role: "user", Content: analysisPrompt},
|
||||
}, &chat.ChatOptions{
|
||||
Temperature: 0.1,
|
||||
Format: formatSchema,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to generate analysis response: %v", err)
|
||||
return next()
|
||||
}
|
||||
// logger.Debugf(ctx, "Data analysis LLM response: %s", response.Content)
|
||||
|
||||
// Execute SQL using the tool
|
||||
// Initialize DataAnalysisTool
|
||||
toolResult, err := tool.Execute(ctx, json.RawMessage(response.Content))
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to execute SQL: %v", err)
|
||||
return next()
|
||||
}
|
||||
|
||||
// 5. Store result
|
||||
// Create a new SearchResult for the analysis output
|
||||
analysisResult := &types.SearchResult{
|
||||
ID: "analysis_" + knowledge.ID,
|
||||
Content: toolResult.Output,
|
||||
Score: 1.0,
|
||||
MatchType: types.MatchTypeDataAnalysis,
|
||||
KnowledgeID: knowledge.ID,
|
||||
KnowledgeTitle: knowledge.Title,
|
||||
KnowledgeFilename: knowledge.FileName,
|
||||
KnowledgeDescription: knowledge.Description,
|
||||
}
|
||||
|
||||
chatManage.MergeResult = append(chatManage.MergeResult, analysisResult)
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
func isDataFile(filename string) bool {
|
||||
lower := strings.ToLower(filename)
|
||||
return strings.HasSuffix(lower, ".csv") || strings.HasSuffix(lower, ".xlsx") || strings.HasSuffix(lower, ".xls")
|
||||
}
|
||||
|
||||
// filterOutTableChunks filters out table column and table summary chunks from search results
|
||||
func filterOutTableChunks(results []*types.SearchResult) []*types.SearchResult {
|
||||
filtered := make([]*types.SearchResult, 0, len(results))
|
||||
filterList := []string{string(types.ChunkTypeTableColumn), string(types.ChunkTypeTableSummary)}
|
||||
for _, result := range results {
|
||||
if slices.Contains(filterList, result.ChunkType) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, result)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
var graphStorageReachInstructionPattern = regexp.MustCompile(`^(?:请\s*)?(?:放\s*置\s*于|置\s*于|放\s*在).+不\s*易\s*触\s*及(?:\s*之)?\s*处$`)
|
||||
|
||||
// This bounded coverage check recognizes explicit placement instructions only.
|
||||
// Unclear OCR stays verbatim; detecting a missing rule must never create an edge.
|
||||
func validateGraphStorageCoverage(graph *types.GraphData, content string) error {
|
||||
for _, clause := range strings.FieldsFunc(content, func(r rune) bool {
|
||||
return strings.ContainsRune(",,。!?!?;;::\n\r", r)
|
||||
}) {
|
||||
clause = strings.TrimSpace(clause)
|
||||
if heading := graphSectionHeadingPattern.FindStringIndex(clause); heading != nil && heading[0] == 0 {
|
||||
clause = strings.TrimSpace(clause[heading[1]:])
|
||||
}
|
||||
if !graphStorageReachInstructionPattern.MatchString(clause) {
|
||||
continue
|
||||
}
|
||||
covered := false
|
||||
for _, relation := range graph.Relation {
|
||||
if relation != nil && relation.Type == "约束" &&
|
||||
strings.Contains(relation.Node2, clause) &&
|
||||
strings.Contains(relation.Evidence, clause) &&
|
||||
strings.Contains(content, relation.Evidence) {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !covered {
|
||||
return fmt.Errorf("%w: explicit storage restriction is not covered by a complete source-grounded constraint: %q; preserve the original clause verbatim without repairing OCR if its relationship is supported, otherwise leave this source for review; never invent an edge to satisfy coverage", ErrGraphNeedsReview, clause)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func graphCoverageResponse(t *testing.T, graph *types.GraphData) *types.ChatResponse {
|
||||
t.Helper()
|
||||
items := make([]map[string]any, 0, len(graph.Node)+len(graph.Relation))
|
||||
for _, node := range graph.Node {
|
||||
items = append(items, map[string]any{"entity": node.Name, "entity_attributes": node.Attributes})
|
||||
}
|
||||
for _, relation := range graph.Relation {
|
||||
items = append(items, map[string]any{"entity1": relation.Node1, "entity2": relation.Node2, "relation": relation.Type, "evidence": relation.Evidence})
|
||||
}
|
||||
raw, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &types.ChatResponse{Content: string(raw), FinishReason: "stop"}
|
||||
}
|
||||
|
||||
func TestGraphStorageCoverageSavedProbeRequiresReview(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/graph_storage_coverage_add45b26.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture struct {
|
||||
SourceSHA256 string `json:"source_sha256"`
|
||||
Source string `json:"source_content"`
|
||||
Graph *types.GraphData `json:"graph"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256([]byte(fixture.Source))
|
||||
if hex.EncodeToString(digest[:]) != fixture.SourceSHA256 || len(fixture.Graph.Relation) != 10 {
|
||||
t.Fatal("saved probe source or returned relations changed")
|
||||
}
|
||||
model := &extractionResponseChat{response: graphCoverageResponse(t, fixture.Graph)}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), fixture.Source)
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), "置于丿 L 童不易触及处") || model.calls != 2 {
|
||||
t.Fatalf("saved omission must require review after one correction: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
if !strings.Contains(model.messages[len(model.messages)-1].Content, "置于丿 L 童不易触及处") {
|
||||
t.Fatal("correction lost the original OCR clause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphStorageCoverageRequiresCompleteConstraint(t *testing.T) {
|
||||
const clause = "置于丿 L 童不易触及处"
|
||||
const source = "本品不得口服," + clause + "。"
|
||||
for _, tc := range []struct {
|
||||
name, target, label, evidence string
|
||||
attribute bool
|
||||
}{
|
||||
{name: "omitted relation"},
|
||||
{name: "attribute alone", attribute: true},
|
||||
{name: "normalized OCR target", target: "置于儿童不易触及处", label: "约束", evidence: clause},
|
||||
{name: "restriction removed from target", target: "置于丿 L 童触及处", label: "约束", evidence: clause},
|
||||
{name: "evidence alone", target: "储存要求", label: "约束", evidence: clause},
|
||||
{name: "unrelated evidence", target: clause, label: "约束", evidence: "本品不得口服"},
|
||||
{name: "wrong relationship", target: clause, label: "关联", evidence: clause},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
input := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "本品", Node2: "口服", Type: "禁用于", Evidence: "本品不得口服"}}}
|
||||
if tc.target != "" {
|
||||
input.Relation = append(input.Relation, &types.GraphRelation{Node1: "本品", Node2: tc.target, Type: tc.label, Evidence: tc.evidence})
|
||||
}
|
||||
if tc.attribute {
|
||||
input.Node = []*types.GraphNode{{Name: "本品", Attributes: []string{clause}}}
|
||||
}
|
||||
model := &extractionResponseChat{response: graphCoverageResponse(t, input)}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) || model.calls != 2 {
|
||||
t.Fatalf("missing constraint passed: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphStorageCoverageCorrectionPreservesOCR(t *testing.T) {
|
||||
const clause = "置于丿 L 童不易触及处"
|
||||
const source = "本品不得口服," + clause + "。"
|
||||
input := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "本品", Node2: "口服", Type: "禁用于", Evidence: "本品不得口服"}}}
|
||||
incomplete := graphCoverageResponse(t, input)
|
||||
input.Relation = append(input.Relation, &types.GraphRelation{Node1: "本品", Node2: clause, Type: "约束", Evidence: clause})
|
||||
model := &extractionResponseChat{responses: []*types.ChatResponse{incomplete, graphCoverageResponse(t, input)}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 2 || model.calls != 2 {
|
||||
t.Fatalf("source-backed correction failed: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
if graph.Relation[1].Node2 != clause || graph.Relation[1].Evidence != clause {
|
||||
t.Fatalf("OCR was changed: %+v", graph.Relation[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphStorageCoverageScope(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
source string
|
||||
wantReview bool
|
||||
}{
|
||||
{"请放置于儿童不易触及之处。", true},
|
||||
{"〔 储存方法 〗 置于丿 L 童不易触及处。", true},
|
||||
{"放在儿童不易触及处。", true},
|
||||
{"教材介绍储存要求。", false},
|
||||
{"作者讨论“置于儿童不易触及处”这句话。", false},
|
||||
{"他认为置于儿童不易触及处能够保证安全。", false},
|
||||
} {
|
||||
err := validateGraphStorageCoverage(&types.GraphData{}, tc.source)
|
||||
if errors.Is(err, ErrGraphNeedsReview) != tc.wantReview {
|
||||
t.Errorf("source=%q, error=%v, want review=%v", tc.source, err, tc.wantReview)
|
||||
}
|
||||
}
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: `[{"entity":"储存"}]`, FinishReason: "stop"}}
|
||||
extractor := NewExtractor(model, &types.PromptTemplateStructured{Description: "Extract query entities"})
|
||||
graph, err := extractor.Extract(context.Background(), "置于丿 L 童不易触及处")
|
||||
if err != nil || graph == nil || len(graph.Node) != 1 || model.calls != 1 {
|
||||
t.Fatalf("query extraction should not require persisted-graph coverage: graph=%+v, err=%v", graph, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
var graphPriceLabelPattern = regexp.MustCompile(`(?:终端)?零售(?:价格|价)|批发(?:价格|价)|促销(?:价格|价)|优惠(?:价格|价)|活动(?:价格|价)|会员(?:价格|价)|建议零售价|销售价格|售价|单价|原价|现价|价格`)
|
||||
var graphPriceAmountPattern = regexp.MustCompile(`^(人民币|[¥¥$])?([0-9]+(?:\.[0-9]+)?)(美元|人民币|元|角|分|盒|箱|瓶|套|支|粒|片|件|袋|kg|mg|ml|g)?(/(?:[0-9一二两俩三四五六七八九十百千]+)?(?:盒|箱|瓶|套|支|粒|片|件|袋|kg|mg|ml|g))?`)
|
||||
var graphPriceHeaderUnitPattern = regexp.MustCompile(`^[((](元|美元|元/(?:盒|箱|瓶|套|支|件))[))]$`)
|
||||
|
||||
type graphPriceValue struct {
|
||||
number string
|
||||
unit string
|
||||
}
|
||||
|
||||
type graphPriceClaim struct {
|
||||
label string
|
||||
values []graphPriceValue
|
||||
prefix string
|
||||
hasQuantity bool
|
||||
}
|
||||
|
||||
func graphPriceValues(value, headerUnit string) []graphPriceValue {
|
||||
value = strings.TrimLeft(compactGraphText(value), "::为是")
|
||||
var values []graphPriceValue
|
||||
for value != "" {
|
||||
match := graphPriceAmountPattern.FindStringSubmatch(value)
|
||||
if match == nil {
|
||||
// Do not accept a parsed prefix while silently ignoring a changed
|
||||
// package quantity or another amount later in the same price claim.
|
||||
if strings.HasPrefix(value, "/") || graphQuantityPattern.MatchString(value) {
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
unit := match[3] + match[4]
|
||||
if match[1] != "" {
|
||||
currency := "元"
|
||||
if match[1] == "$" {
|
||||
currency = "美元"
|
||||
}
|
||||
if unit == "" || strings.HasPrefix(unit, "/") {
|
||||
unit = currency + unit
|
||||
}
|
||||
}
|
||||
if unit == "" {
|
||||
unit = headerUnit
|
||||
}
|
||||
values = append(values, graphPriceValue{number: match[2], unit: unit})
|
||||
value = strings.TrimLeft(value[len(match[0]):], ",,、")
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func graphPriceClaims(value string) []graphPriceClaim {
|
||||
value = compactGraphText(value)
|
||||
labels := graphPriceLabelPattern.FindAllStringIndex(value, -1)
|
||||
var claims []graphPriceClaim
|
||||
for i, span := range labels {
|
||||
end := len(value)
|
||||
if i+1 < len(labels) {
|
||||
end = labels[i+1][0]
|
||||
}
|
||||
prefix := value[:span[0]]
|
||||
if index := strings.LastIndexAny(prefix, ",,;;。|::"); index >= 0 {
|
||||
prefix = strings.TrimLeft(prefix[index:], ",,;;。|::")
|
||||
}
|
||||
claims = append(claims, graphPriceClaim{
|
||||
label: graphPriceLabel(value[span[0]:span[1]]),
|
||||
values: graphPriceValues(value[span[1]:end], ""),
|
||||
prefix: prefix,
|
||||
hasQuantity: graphQuantityPattern.MatchString(value[span[1]:end]),
|
||||
})
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func graphPriceLabel(value string) string {
|
||||
return strings.TrimPrefix(strings.ReplaceAll(value, "价格", "价"), "终端")
|
||||
}
|
||||
|
||||
func graphPriceValuesMatch(want, available []graphPriceValue) bool {
|
||||
if len(want) == 0 || len(want) > len(available) {
|
||||
return false
|
||||
}
|
||||
for start := 0; start+len(want) <= len(available); start++ {
|
||||
matches := true
|
||||
for i, value := range want {
|
||||
actual := available[start+i]
|
||||
if value.number != actual.number || (value.unit != "" && value.unit != actual.unit) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func graphPricePrefixMatches(prefix, name string, inherited bool) bool {
|
||||
prefix = strings.TrimSuffix(compactGraphText(prefix), "的")
|
||||
if prefix == compactGraphText(name) {
|
||||
return true
|
||||
}
|
||||
return inherited && (prefix == "" || prefix == "本品" || prefix == "该产品" || prefix == "其")
|
||||
}
|
||||
|
||||
func graphPriceSentenceSupports(sentence, name string, inherited bool, want graphPriceClaim) bool {
|
||||
for _, claim := range graphPriceClaims(sentence) {
|
||||
if !graphPricePrefixMatches(claim.prefix, name, inherited) {
|
||||
inherited = false
|
||||
continue
|
||||
}
|
||||
inherited = true
|
||||
if claim.label == want.label && graphPriceValuesMatch(want.values, claim.values) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func graphPriceTableCells(line string) []string {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "|") {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(strings.TrimSuffix(strings.TrimPrefix(line, "|"), "|"), "|")
|
||||
}
|
||||
|
||||
func graphPriceProductHeader(value string) bool {
|
||||
switch compactGraphText(value) {
|
||||
case "产品名称", "商品名称", "品名", "产品":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func graphPriceTableRowMatches(cells, headers []string, name string) bool {
|
||||
name = compactGraphText(name)
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
if len(headers) == 0 {
|
||||
for _, cell := range cells {
|
||||
if compactGraphText(cell) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if len(cells) > len(headers) {
|
||||
return false
|
||||
}
|
||||
productColumn, brandColumn := -1, -1
|
||||
for i, header := range headers {
|
||||
if graphPriceProductHeader(header) {
|
||||
if productColumn >= 0 {
|
||||
return false
|
||||
}
|
||||
productColumn = i
|
||||
}
|
||||
if header = compactGraphText(header); header == "品牌" || header == "品牌名称" {
|
||||
if brandColumn >= 0 {
|
||||
return false
|
||||
}
|
||||
brandColumn = i
|
||||
}
|
||||
}
|
||||
if productColumn < 0 || productColumn >= len(cells) {
|
||||
return false
|
||||
}
|
||||
product := compactGraphText(cells[productColumn])
|
||||
if product == "" {
|
||||
return false
|
||||
}
|
||||
if product == name {
|
||||
return true
|
||||
}
|
||||
if brandColumn < 0 || brandColumn >= len(cells) {
|
||||
return false
|
||||
}
|
||||
brand := compactGraphText(cells[brandColumn])
|
||||
return brand != "" && brand+product == name
|
||||
}
|
||||
|
||||
func graphPriceAttributeSupported(content, name string, want graphPriceClaim) bool {
|
||||
var headers []string
|
||||
headingMatches := false
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if cells := graphPriceTableCells(line); cells != nil {
|
||||
headingMatches = false
|
||||
isHeader := false
|
||||
for _, cell := range cells {
|
||||
if graphPriceProductHeader(cell) {
|
||||
isHeader = true
|
||||
}
|
||||
}
|
||||
if isHeader {
|
||||
headers = cells
|
||||
continue
|
||||
}
|
||||
// A short row may end midway through its final cell. Only closed
|
||||
// cells have a known boundary; never validate a truncated price.
|
||||
if len(cells) < len(headers) && !strings.HasSuffix(strings.TrimSpace(line), "|") {
|
||||
cells = cells[:len(cells)-1]
|
||||
}
|
||||
if !graphPriceTableRowMatches(cells, headers, name) {
|
||||
continue
|
||||
}
|
||||
for i, cell := range cells {
|
||||
if graphPriceSentenceSupports(cell, name, true, want) {
|
||||
return true
|
||||
}
|
||||
// A chunk may end before the row's final cells; only existing
|
||||
// leading cells can inherit their corresponding explicit header.
|
||||
if i >= len(headers) {
|
||||
continue
|
||||
}
|
||||
header := compactGraphText(headers[i])
|
||||
span := graphPriceLabelPattern.FindStringIndex(header)
|
||||
if span == nil || span[0] != 0 || graphPriceLabel(header[:span[1]]) != want.label {
|
||||
continue
|
||||
}
|
||||
unit := ""
|
||||
if suffix := header[span[1]:]; suffix != "" {
|
||||
match := graphPriceHeaderUnitPattern.FindStringSubmatch(suffix)
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
unit = match[1]
|
||||
}
|
||||
if graphPriceValuesMatch(want.values, graphPriceValues(cell, unit)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
headers = nil
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") || graphProductNameMarkerPattern.MatchString(trimmed) {
|
||||
heading := strings.TrimLeft(trimmed, "# ")
|
||||
heading = strings.TrimPrefix(graphProductIdentity(heading), "产品名称")
|
||||
headingMatches = heading == graphProductIdentity(name)
|
||||
continue
|
||||
}
|
||||
for _, sentence := range strings.FieldsFunc(line, func(r rune) bool { return strings.ContainsRune("。!?!?;;", r) }) {
|
||||
if graphPriceSentenceSupports(sentence, name, headingMatches, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateGraphPriceAttributes(graph *types.GraphData, content string) error {
|
||||
for i, node := range graph.Node {
|
||||
for j, attribute := range node.Attributes {
|
||||
for _, claim := range graphPriceClaims(attribute) {
|
||||
if !claim.hasQuantity {
|
||||
continue
|
||||
}
|
||||
if !graphPriceAttributeSupported(content, node.Name, claim) {
|
||||
return fmt.Errorf("%w: entity %d attribute %d has an unsupported price label/value (%s); require an explicit label and matching amount/unit in this product's sentence or table column, never infer unheaded prices", ErrGraphNeedsReview, i+1, j+1, claim.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// Replay private evidence without checking source material into the repository
|
||||
// or invoking the model. The original failed response must remain rejected.
|
||||
func TestGraphPricePrivateReplayKeepsProductScopeRejection(t *testing.T) {
|
||||
path := os.Getenv("GRAPH_PRICE_REPLAY_REPORT")
|
||||
if path == "" {
|
||||
t.Skip("private replay report not configured")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal("cannot read private replay report")
|
||||
}
|
||||
var report struct {
|
||||
ChunkID string `json:"chunk_id"`
|
||||
Source string `json:"source_content"`
|
||||
SourceSHA256 string `json:"source_sha256"`
|
||||
RejectedContent string `json:"rejected_model_content"`
|
||||
ProductionWrite bool `json:"production_write"`
|
||||
Passed bool `json:"passed"`
|
||||
}
|
||||
if json.Unmarshal(data, &report) != nil {
|
||||
t.Fatal("invalid private replay report")
|
||||
}
|
||||
hash := sha256.Sum256([]byte(report.Source))
|
||||
if report.ChunkID != "e40b74eb-0ac7-4ff6-a107-00ce5adfe610" ||
|
||||
report.SourceSHA256 != "4c4d84679328fc6dd3c17add7a91d13669c2d0cc2bc507098ed4fcc1c0dd84b2" ||
|
||||
hex.EncodeToString(hash[:]) != report.SourceSHA256 || report.RejectedContent == "" ||
|
||||
report.ProductionWrite || report.Passed {
|
||||
t.Fatal("private replay evidence does not match the reviewed failed candidate")
|
||||
}
|
||||
graph, err := NewFormater().parseGraph(context.Background(), report.RejectedContent, &report.Source)
|
||||
if graph != nil || err == nil ||
|
||||
!strings.Contains(err.Error(), "assigns an earlier unnamed product fragment to a later named product") {
|
||||
t.Fatal("replayed cross-product relationships were not rejected")
|
||||
}
|
||||
if strings.Contains(err.Error(), "unsupported price label/value") {
|
||||
t.Fatal("explicit price in the reviewed table is still falsely rejected")
|
||||
}
|
||||
var sections struct {
|
||||
Sections []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"source_sections"`
|
||||
}
|
||||
if json.Unmarshal([]byte(graphProductSourcePrompt(report.Source)), §ions) != nil {
|
||||
t.Fatal("reviewed multi-product source was not sectioned")
|
||||
}
|
||||
var reconstructed strings.Builder
|
||||
for _, section := range sections.Sections {
|
||||
reconstructed.WriteString(section.Text)
|
||||
}
|
||||
if reconstructed.String() != report.Source || len(sections.Sections) != 3 {
|
||||
t.Fatal("reviewed source sections changed original source or table boundaries")
|
||||
}
|
||||
// This is an offline feasibility check, not a production repair: only the
|
||||
// two reviewed mistaken subjects change. Every quotation and price stays.
|
||||
var items []map[string]any
|
||||
if json.Unmarshal([]byte(NewFormater().extractContent(context.Background(), report.RejectedContent)), &items) != nil {
|
||||
t.Fatal("invalid reviewed response")
|
||||
}
|
||||
changed := 0
|
||||
for _, item := range items {
|
||||
from, _ := item["entity1"].(string)
|
||||
quote, _ := item["evidence"].(string)
|
||||
if from != "" && quote != "" && graphBorrowsLaterProduct(report.Source, &types.GraphRelation{Node1: from, Evidence: quote}) {
|
||||
item["entity1"] = "本品"
|
||||
changed++
|
||||
}
|
||||
}
|
||||
if changed != 2 {
|
||||
t.Fatal("reviewed scope rejection count changed")
|
||||
}
|
||||
repaired, _ := json.Marshal(items)
|
||||
if graph, err := NewFormater().parseGraph(context.Background(), string(repaired), &report.Source); err != nil || graph == nil {
|
||||
t.Fatal("scope-only offline correction did not pass unchanged source/price validation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestGraphPriceAttributesRequireExplicitProductEvidence(t *testing.T) {
|
||||
const unheaded = "| 私觅玉丹 | 紧紧丹 | 0.5g/7粒 | 39元/盒 | 34元/一盒 99盒/箱 | 199元/一盒 399元/三盒 |\n\n| 私觅玉丹 | 水光胶原棒 | 3g/6支 | 39元/盒 | 34元/盒 99盒/箱 | 199元/一盒 399元/三盒 |"
|
||||
const table = "| 产品名称 | 零售价 | 批发价 |\n| --- | --- | --- |\n| 甲产品 | 39元/盒 | 34元/盒 |\n| 乙产品 | 59元/盒 | 54元/盒 |"
|
||||
for _, tc := range []struct {
|
||||
name, source, product, attribute string
|
||||
valid bool
|
||||
}{
|
||||
{name: "probe cannot infer retail column", source: unheaded, product: "紧紧丹", attribute: "零售价:39元/盒"},
|
||||
{name: "probe cannot infer wholesale column", source: unheaded, product: "紧紧丹", attribute: "批发价:34元/一盒,99盒/箱"},
|
||||
{name: "probe cannot infer promotion column", source: unheaded, product: "水光胶原棒", attribute: "促销价:199元/一盒,399元/三盒"},
|
||||
{name: "raw source amount does not invent price category", source: unheaded, product: "紧紧丹", attribute: "39元/盒", valid: true},
|
||||
{name: "non price summaries remain supported", source: "甲产品采用简便包装,每盒7粒。", product: "甲产品", attribute: "包装便于使用;规格:7粒/盒", valid: true},
|
||||
{name: "unresolved price without amount is not a price assertion", source: "甲产品每盒7粒,价格待确认。", product: "甲产品", attribute: "规格:7粒/盒;价格待确认", valid: true},
|
||||
{name: "explicit sentence", source: "甲产品的零售价为39元/盒。", product: "甲产品", attribute: "零售价:39元/盒", valid: true},
|
||||
{name: "sentence punctuation and spaces may differ", source: "甲产品零售价格:39 元 / 盒,批发价:34元/盒。", product: "甲产品", attribute: "零售价39元/盒;批发价格34元/盒", valid: true},
|
||||
{name: "label cannot borrow other labelled value", source: "甲产品零售价39元/盒,批发价34元/盒。", product: "甲产品", attribute: "零售价:34元/盒"},
|
||||
{name: "cannot borrow another product sentence", source: "甲产品零售价39元/盒。乙产品零售价59元/盒。", product: "甲产品", attribute: "零售价:59元/盒"},
|
||||
{name: "cannot borrow another product in same sentence", source: "甲产品零售价39元/盒,乙产品零售价59元/盒。", product: "甲产品", attribute: "零售价:59元/盒"},
|
||||
{name: "cannot match product name substring", source: "甲产品加强版零售价39元/盒。", product: "甲产品", attribute: "零售价:39元/盒"},
|
||||
{name: "different packaging unit", source: "甲产品零售价39元/箱。", product: "甲产品", attribute: "零售价:39元/盒"},
|
||||
{name: "different amount cannot match numeric substring", source: "甲产品零售价399元/盒。", product: "甲产品", attribute: "零售价:39元/盒"},
|
||||
{name: "promotion pack amounts preserved", source: "甲产品促销价199元/一盒,399元/三盒。", product: "甲产品", attribute: "促销价:199元/一盒,399元/三盒", valid: true},
|
||||
{name: "promotion pack counts cannot change", source: "甲产品促销价199元/一盒,399元/三盒。", product: "甲产品", attribute: "促销价:399元/一盒"},
|
||||
{name: "unparsed packaging count cannot be ignored", source: "甲产品批发价34元/盒,99盒/箱。", product: "甲产品", attribute: "批发价34元/盒(199盒/箱)"},
|
||||
{name: "table row and header", source: table, product: "甲产品", attribute: "零售价:39元/盒;批发价:34元/盒", valid: true},
|
||||
{name: "table cannot swap columns", source: table, product: "甲产品", attribute: "零售价:34元/盒"},
|
||||
{name: "table cannot borrow other row", source: table, product: "甲产品", attribute: "零售价:59元/盒"},
|
||||
{name: "table cannot use shared brand as product", source: "| 品牌 | 产品名称 | 零售价 |\n| --- | --- | --- |\n| 甲 | 甲精华 | 39元/盒 |", product: "甲", attribute: "零售价:39元/盒"},
|
||||
{name: "table header supplies explicit unit", source: "| 产品名称 | 零售价(元/盒) |\n| --- | --- |\n| 甲产品 | 39 |", product: "甲产品", attribute: "零售价:39元/盒", valid: true},
|
||||
{name: "table missing unit cannot invent yuan", source: "| 产品名称 | 零售价 |\n| --- | --- |\n| 甲产品 | 39 |", product: "甲产品", attribute: "零售价:39元/盒"},
|
||||
{name: "header cannot bleed into another table", source: table + "\n\n| 丙产品 | 69元/盒 | 64元/盒 |", product: "丙产品", attribute: "零售价:69元/盒"},
|
||||
{name: "headerless row with explicit cell label", source: "| 甲产品 | 零售价:39元/盒 |", product: "甲产品", attribute: "零售价:39元/盒", valid: true},
|
||||
{name: "named section", source: "〔产品名称〕甲产品\n零售价:39元/盒。\n\n〔产品名称〕乙产品\n零售价:59元/盒。", product: "甲产品", attribute: "零售价:39元/盒", valid: true},
|
||||
{name: "sections cannot borrow price", source: "〔产品名称〕甲产品\n零售价:39元/盒。\n\n〔产品名称〕乙产品\n零售价:59元/盒。", product: "甲产品", attribute: "零售价:59元/盒"},
|
||||
{name: "numeric unsupported phrasing cannot bypass validation", source: "甲产品39元/盒。", product: "甲产品", attribute: "零售价约39元/盒"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
graph := &types.GraphData{Node: []*types.GraphNode{{Name: tc.product, Attributes: []string{tc.attribute}}}}
|
||||
err := validateGraphPriceAttributes(graph, tc.source)
|
||||
if tc.valid && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tc.valid && !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatalf("ambiguous price must require review: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(graph.Node[0].Attributes, []string{tc.attribute}) {
|
||||
t.Fatal("validation silently rewrote or removed an attribute")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphPriceTableFormattingPreservesProductAndPackageScope(t *testing.T) {
|
||||
const headers = "| 品牌名称 | 产品名称 | 图片 | 规格 | 终端零售价 | 产品说明 | 其他 |\n| --- | --- | --- | --- | --- | --- | --- |\n"
|
||||
const truncatedRow = "| 甲品牌 | 甲精华 | | 30ml/1瓶 | 139元/一瓶 239元/俩瓶 | 原文说明"
|
||||
const fullRow = truncatedRow + " | 补充说明 |"
|
||||
const otherRow = "| 乙品牌 | 乙精华 | | 30ml/1瓶 | 159元/一瓶 259元/俩瓶 | 原文说明 | 补充说明 |"
|
||||
for _, tc := range []struct {
|
||||
name, source, product, attribute string
|
||||
valid bool
|
||||
}{
|
||||
{name: "complete row with same-row brand and product", source: headers + fullRow, product: "甲品牌 甲精华", attribute: "终端零售价:139元/一瓶 239元/俩瓶", valid: true},
|
||||
{name: "trailing pipe is optional", source: headers + strings.TrimSuffix(fullRow, "|"), product: "甲精华", attribute: "终端零售价:239元/俩瓶", valid: true},
|
||||
{name: "header trailing pipe is optional", source: strings.ReplaceAll(headers, "|\n", "\n") + fullRow, product: "甲精华", attribute: "终端零售价:239元/俩瓶", valid: true},
|
||||
{name: "available columns in truncated row", source: headers + truncatedRow, product: "甲品牌甲精华", attribute: "终端零售价:139元/一瓶 239元/俩瓶", valid: true},
|
||||
{name: "available columns with truncated row delimiter", source: headers + truncatedRow + " |", product: "甲精华", attribute: "零售价:239元/俩瓶", valid: true},
|
||||
{name: "terminal retail sentence", source: "甲精华终端零售价格139元/一瓶,239元/俩瓶。", product: "甲精华", attribute: "终端零售价:139元/一瓶,239元/俩瓶", valid: true},
|
||||
{name: "terminal retail is explicit retail category", source: headers + fullRow, product: "甲精华", attribute: "零售价:239元/俩瓶", valid: true},
|
||||
{name: "brand alone cannot own the price", source: headers + fullRow, product: "甲品牌", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "product substring cannot own the price", source: headers + fullRow, product: "甲精", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "brand substring cannot own the price", source: headers + fullRow, product: "甲牌甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "brand and product must come from same row", source: headers + fullRow + "\n" + otherRow, product: "乙品牌甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "same product under another brand cannot supply price", source: headers + fullRow + "\n" + strings.Replace(otherRow, "乙精华", "甲精华", 1), product: "甲品牌甲精华", attribute: "终端零售价:259元/俩瓶"},
|
||||
{name: "another row cannot supply price", source: headers + truncatedRow + "\n" + otherRow, product: "甲品牌甲精华", attribute: "终端零售价:259元/俩瓶"},
|
||||
{name: "two bottle price cannot become single bottle", source: headers + truncatedRow, product: "甲精华", attribute: "终端零售价:239元/一瓶"},
|
||||
{name: "two bottle quantity cannot be dropped", source: headers + truncatedRow, product: "甲精华", attribute: "终端零售价:239元/瓶"},
|
||||
{name: "two bottle unit cannot become box", source: headers + truncatedRow, product: "甲精华", attribute: "终端零售价:239元/俩盒"},
|
||||
{name: "two bottle token cannot be silently changed", source: headers + truncatedRow, product: "甲精华", attribute: "终端零售价:239元/两瓶"},
|
||||
{name: "unclosed price in a short row cannot be accepted", source: headers + "| 甲品牌 | 甲精华 | | 30ml/1瓶 | 239元", product: "甲精华", attribute: "终端零售价:239元"},
|
||||
{name: "truncated package cannot be ignored", source: "甲精华终端零售价239元/俩", product: "甲精华", attribute: "终端零售价:239元"},
|
||||
{name: "unknown package count cannot be ignored", source: "甲精华终端零售价239元/仨瓶", product: "甲精华", attribute: "终端零售价:239元"},
|
||||
{name: "terminal retail cannot become suggested retail", source: headers + truncatedRow, product: "甲精华", attribute: "建议零售价:239元/俩瓶"},
|
||||
{name: "terminal retail cannot become wholesale", source: headers + truncatedRow, product: "甲精华", attribute: "批发价:239元/俩瓶"},
|
||||
{name: "unheaded amounts still cannot infer retail", source: truncatedRow, product: "甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "missing price column cannot borrow other cells", source: headers + "| 甲品牌 | 甲精华 | 239元/俩瓶", product: "甲品牌甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "missing product column cannot borrow heading", source: "| 品牌名称 | 终端零售价 | 产品名称 |\n| --- | --- | --- |\n| 甲品牌 | 239元/俩瓶", product: "甲品牌甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "blank product cannot match brand", source: headers + strings.Replace(fullRow, "甲精华", "", 1), product: "甲品牌", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "overlong row is ambiguous", source: headers + fullRow + " 多余单元格 |", product: "甲品牌甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "header cannot survive intervening prose", source: headers + "表格结束\n" + truncatedRow, product: "甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
graph := &types.GraphData{Node: []*types.GraphNode{{Name: tc.product, Attributes: []string{tc.attribute}}}}
|
||||
err := validateGraphPriceAttributes(graph, tc.source)
|
||||
if tc.valid && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tc.valid && !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatalf("unsupported product, column or package was accepted: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(graph.Node[0].Attributes, []string{tc.attribute}) {
|
||||
t.Fatal("validation rewrote the source-supported attribute")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func graphPriceTestResponse(t *testing.T, attribute string) *types.ChatResponse {
|
||||
t.Helper()
|
||||
items := []map[string]interface{}{
|
||||
{"entity": "甲产品", "entity_attributes": []string{attribute}},
|
||||
{"entity1": "甲产品", "entity2": "成分甲", "relation": "包含", "evidence": "甲产品包含成分甲"},
|
||||
}
|
||||
raw, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &types.ChatResponse{Content: string(raw), FinishReason: "stop"}
|
||||
}
|
||||
|
||||
func TestGraphPriceAmbiguityRequiresReviewAfterBoundedCorrection(t *testing.T) {
|
||||
const source = "甲产品包含成分甲。\n| 甲产品 | 39元/盒 | 34元/盒 |"
|
||||
model := &extractionResponseChat{response: graphPriceTestResponse(t, "零售价:39元/盒")}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) || model.calls != 2 {
|
||||
t.Fatalf("ambiguous attribute completed or retried indefinitely: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
if !strings.Contains(model.messages[len(model.messages)-1].Content, "unsupported price label/value") {
|
||||
t.Fatal("bounded correction did not receive the price attribution failure")
|
||||
}
|
||||
if !strings.Contains(model.messages[0].Content, "无表头的金额列不能推断") {
|
||||
t.Fatal("graph instruction must cover headerless sources without product markers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphPriceCorrectionCanRestoreExplicitPrice(t *testing.T) {
|
||||
const source = "甲产品包含成分甲。甲产品零售价39元/盒,批发价34元/盒。"
|
||||
model := &extractionResponseChat{responses: []*types.ChatResponse{
|
||||
graphPriceTestResponse(t, "零售价:34元/盒"),
|
||||
graphPriceTestResponse(t, "零售价:39元/盒"),
|
||||
}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 1 || model.calls != 2 {
|
||||
t.Fatalf("supported price correction failed: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
if !reflect.DeepEqual(graph.Node[0].Attributes, []string{"零售价:39元/盒"}) {
|
||||
t.Fatal("corrected supported price was not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryExtractionDoesNotRequirePriceProvenance(t *testing.T) {
|
||||
model := &extractionResponseChat{response: graphPriceTestResponse(t, "零售价:39元/盒")}
|
||||
extractor := NewExtractor(model, &types.PromptTemplateStructured{Description: "Extract query entities"})
|
||||
graph, err := extractor.Extract(context.Background(), "查询甲产品零售价")
|
||||
if err != nil || graph == nil || model.calls != 1 {
|
||||
t.Fatalf("graph-only price validation changed query extraction: %+v, %v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// These are labelled source lists, not medical classifications. All offsets
|
||||
// refer to the unchanged chunk; normalized text is used only for comparison.
|
||||
var graphListHeadingPattern = regexp.MustCompile(`(?m)[〔〖【\[][^〔〖【\[〕〗】\]\n]{1,40}[〕〗】\]]|^[ \t]*[^\n::〔〖【\[]{1,30}[::]`)
|
||||
var graphListPredicatePattern = regexp.MustCompile(`谨\s*慎\s*使\s*用|慎\s*[用甩](?:\s*于)?|不\s*适\s*[宜用合](?:\s*于)?|不\s*宜(?:\s*于)?|禁\s*止\s*使\s*用|禁\s*用(?:\s*于)?|不\s*[得可]\s*使\s*用|不\s*能\s*使\s*用`)
|
||||
var graphListAmbiguityPattern = regexp.MustCompile(`[和及与或但若如除仅]|以及|同时|慎|禁|不适|使用|服用|适用|适宜|用于`)
|
||||
|
||||
type graphRestrictionMember struct {
|
||||
start, end int
|
||||
target string
|
||||
sourceTarget string
|
||||
predicate string
|
||||
label string
|
||||
explicit bool
|
||||
ambiguous bool
|
||||
}
|
||||
|
||||
type graphRestrictionScope struct {
|
||||
start, end, headingEnd int
|
||||
section int
|
||||
ownerText string
|
||||
heading string
|
||||
label string
|
||||
members []graphRestrictionMember
|
||||
ambiguous bool
|
||||
}
|
||||
|
||||
func graphRestrictionLabel(predicate string) string {
|
||||
switch strings.TrimSuffix(graphComparisonText(predicate), "于") {
|
||||
case "慎用", "慎甩", "谨慎使用":
|
||||
return "慎用于"
|
||||
case "不适宜", "不适用", "不适合", "不宜":
|
||||
return "不适用于"
|
||||
case "禁用", "禁止使用", "不得使用", "不可使用", "不能使用":
|
||||
return "禁用于"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func graphRestrictionHeadingLabel(heading string) (string, bool) {
|
||||
name := strings.Trim(graphComparisonText(heading), "〔〖【[〕〗】]::")
|
||||
for _, suffix := range []string{"人群", "对象"} {
|
||||
if !strings.HasSuffix(name, suffix) {
|
||||
continue
|
||||
}
|
||||
predicate := strings.TrimSuffix(name, suffix)
|
||||
label := graphRestrictionLabel(predicate)
|
||||
// An OCR-damaged restrictive heading stays a literal block. It must not
|
||||
// lend a guessed typed predicate to its members.
|
||||
return label, label != "" || strings.HasPrefix(predicate, "不") ||
|
||||
strings.HasPrefix(predicate, "慎") || strings.HasPrefix(predicate, "禁")
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func graphTrimSpan(content string, start, end int) (int, int) {
|
||||
text := content[start:end]
|
||||
left := strings.TrimLeftFunc(text, unicode.IsSpace)
|
||||
start += len(text) - len(left)
|
||||
end = start + len(strings.TrimRightFunc(left, unicode.IsSpace))
|
||||
return start, end
|
||||
}
|
||||
|
||||
func graphRestrictionScopes(content string) []graphRestrictionScope {
|
||||
if !graphHasProductDocumentation(content) {
|
||||
return nil
|
||||
}
|
||||
var scopes []graphRestrictionScope
|
||||
offset := 0
|
||||
for sectionIndex, section := range graphProductSections(content) {
|
||||
headings := graphListHeadingPattern.FindAllStringIndex(section, -1)
|
||||
for index, heading := range headings {
|
||||
label, recognized := graphRestrictionHeadingLabel(section[heading[0]:heading[1]])
|
||||
if !recognized {
|
||||
continue
|
||||
}
|
||||
end := len(section)
|
||||
if index+1 < len(headings) {
|
||||
end = headings[index+1][0]
|
||||
}
|
||||
body := section[heading[1]:end]
|
||||
if boundary := strings.IndexAny(body, "。!?!?"); boundary >= 0 {
|
||||
end = heading[1] + boundary
|
||||
}
|
||||
if boundary := strings.Index(section[heading[1]:end], "\n\n"); boundary >= 0 {
|
||||
end = heading[1] + boundary
|
||||
}
|
||||
start, end := graphTrimSpan(content, offset+heading[0], offset+end)
|
||||
scope := graphRestrictionScope{
|
||||
start: start, end: end, headingEnd: offset + heading[1],
|
||||
section: sectionIndex + 1, ownerText: section[:heading[0]],
|
||||
heading: section[heading[0]:heading[1]], label: label, ambiguous: label == "",
|
||||
}
|
||||
memberStart := scope.headingEnd
|
||||
for i, r := range content[scope.headingEnd:scope.end] {
|
||||
if !strings.ContainsRune("、,,;;", r) {
|
||||
continue
|
||||
}
|
||||
memberEnd := scope.headingEnd + i
|
||||
scope.members = append(scope.members, graphParseRestrictionMember(content, memberStart, memberEnd, label))
|
||||
memberStart = memberEnd + len(string(r))
|
||||
}
|
||||
scope.members = append(scope.members, graphParseRestrictionMember(content, memberStart, scope.end, label))
|
||||
for i, member := range scope.members {
|
||||
scope.ambiguous = scope.ambiguous || member.ambiguous
|
||||
// A suffix predicate after bare coordinated items may govern all of
|
||||
// them. Only a heading can supply the omitted predicate here.
|
||||
if i > 0 && member.explicit && !scope.members[i-1].explicit {
|
||||
scope.ambiguous = true
|
||||
}
|
||||
if member.target != "" && strings.Count(content[scope.headingEnd:scope.end], member.target) != 1 {
|
||||
scope.ambiguous = true
|
||||
}
|
||||
}
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
offset += len(section)
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
func graphParseRestrictionMember(content string, start, end int, headingLabel string) graphRestrictionMember {
|
||||
start, end = graphTrimSpan(content, start, end)
|
||||
member := graphRestrictionMember{start: start, end: end, label: headingLabel}
|
||||
raw := content[start:end]
|
||||
predicates := graphListPredicatePattern.FindAllStringIndex(raw, -1)
|
||||
if len(predicates) == 0 {
|
||||
member.target = raw
|
||||
member.sourceTarget = raw
|
||||
} else if len(predicates) == 1 {
|
||||
predicate := predicates[0]
|
||||
member.explicit = true
|
||||
member.predicate = raw[predicate[0]:predicate[1]]
|
||||
member.label = graphRestrictionLabel(member.predicate)
|
||||
before, after := strings.TrimSpace(raw[:predicate[0]]), strings.TrimSpace(raw[predicate[1]:])
|
||||
switch {
|
||||
case before != "" && after == "":
|
||||
member.sourceTarget = before
|
||||
member.target = strings.TrimSpace(strings.TrimPrefix(before, "对"))
|
||||
case before == "" && after != "":
|
||||
member.target = after
|
||||
member.sourceTarget = after
|
||||
default:
|
||||
member.ambiguous = true
|
||||
}
|
||||
} else {
|
||||
member.ambiguous = true
|
||||
}
|
||||
member.ambiguous = member.ambiguous || member.target == "" || member.label == "" ||
|
||||
graphListAmbiguityPattern.MatchString(graphComparisonText(member.target))
|
||||
return member
|
||||
}
|
||||
|
||||
func graphRestrictionTypeMatches(expected, actual string) bool {
|
||||
return expected == actual || (expected == "禁用于" && actual == "不能用于")
|
||||
}
|
||||
|
||||
func graphRestrictionTargetMatches(member graphRestrictionMember, target string) bool {
|
||||
return target != "" && (target == member.target || target == member.sourceTarget)
|
||||
}
|
||||
|
||||
// Display comparisons remove whitespace only, never case or OCR distinctions.
|
||||
func graphRestrictionDisplayText(value string) string {
|
||||
return strings.Join(strings.Fields(value), "")
|
||||
}
|
||||
|
||||
func graphRestrictionHeadingName(heading string) string {
|
||||
return strings.Trim(graphRestrictionDisplayText(heading), "〔〖【[〕〗】]::")
|
||||
}
|
||||
|
||||
func graphRestrictionDisplayParts(value string) (heading, body string, ok bool) {
|
||||
value = graphRestrictionDisplayText(value)
|
||||
span := graphListHeadingPattern.FindStringIndex(value)
|
||||
if span == nil || span[0] != 0 {
|
||||
return "", "", false
|
||||
}
|
||||
heading, body = value[:span[1]], value[span[1]:]
|
||||
_, ok = graphRestrictionHeadingLabel(heading)
|
||||
return heading, body, ok
|
||||
}
|
||||
|
||||
func graphRestrictionCompleteTargetMatches(scope graphRestrictionScope, target, content string) bool {
|
||||
heading, body, ok := graphRestrictionDisplayParts(target)
|
||||
if !ok || body == "" || body != graphRestrictionDisplayText(content[scope.headingEnd:scope.end]) {
|
||||
return false
|
||||
}
|
||||
sourceHeading := graphRestrictionDisplayText(scope.heading)
|
||||
name := graphRestrictionHeadingName(scope.heading)
|
||||
return heading == sourceHeading || heading == name+":" || heading == name+":"
|
||||
}
|
||||
|
||||
func graphRestrictionSubjectMatches(scope graphRestrictionScope, subject string) bool {
|
||||
return graphGenericProduct(subject) || (graphProductIdentity(subject) != "" &&
|
||||
strings.Contains(graphProductIdentity(scope.ownerText), graphProductIdentity(subject)))
|
||||
}
|
||||
|
||||
func graphRestrictionEvidenceTouches(scope graphRestrictionScope, evidence, content string) bool {
|
||||
if evidence == "" {
|
||||
return false
|
||||
}
|
||||
for offset := 0; offset < len(content); {
|
||||
index := strings.Index(content[offset:], evidence)
|
||||
if index < 0 {
|
||||
break
|
||||
}
|
||||
start := offset + index
|
||||
end := start + len(evidence)
|
||||
if start < scope.end && end > scope.headingEnd {
|
||||
return true
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Independent source/target anchors allow semantic diagnostics even when the
|
||||
// model changed its quotation. They never repair that quotation or output edge.
|
||||
func graphRestrictionRelation(content string, relation *types.GraphRelation, scopes []graphRestrictionScope) ([]string, bool, error) {
|
||||
if relation == nil || len(scopes) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
target := strings.TrimSpace(relation.Node2)
|
||||
type match struct {
|
||||
scope graphRestrictionScope
|
||||
member int
|
||||
whole bool
|
||||
}
|
||||
var candidates []match
|
||||
related := false
|
||||
for _, scope := range scopes {
|
||||
whole := graphRestrictionCompleteTargetMatches(scope, target, content)
|
||||
memberIndex := -1
|
||||
for i, member := range scope.members {
|
||||
if graphRestrictionTargetMatches(member, target) {
|
||||
memberIndex = i
|
||||
}
|
||||
}
|
||||
touches := graphRestrictionEvidenceTouches(scope, relation.Evidence, content)
|
||||
useLabel := graphPositiveUseLabel(relation.Type) || relation.Type == "慎用于" ||
|
||||
relation.Type == "禁用于" || relation.Type == "不适用于" || relation.Type == "不能用于"
|
||||
if !whole && memberIndex < 0 && !(touches && (useLabel || relation.Type == "关联" || relation.Type == "约束")) {
|
||||
continue
|
||||
}
|
||||
related = true
|
||||
if graphRestrictionSubjectMatches(scope, relation.Node1) {
|
||||
candidates = append(candidates, match{scope: scope, member: memberIndex, whole: whole})
|
||||
}
|
||||
}
|
||||
if !related {
|
||||
return nil, false, nil
|
||||
}
|
||||
if len(candidates) > 1 && strings.Contains(content, relation.Evidence) && relation.Evidence != "" {
|
||||
var anchored []match
|
||||
for _, candidate := range candidates {
|
||||
if graphRestrictionEvidenceTouches(candidate.scope, relation.Evidence, content) {
|
||||
anchored = append(anchored, candidate)
|
||||
}
|
||||
}
|
||||
candidates = anchored
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
return nil, true, fmt.Errorf("%w: restriction list has ambiguous target or product scope; preserve a complete literal list constraint with source-specific evidence", ErrGraphNeedsReview)
|
||||
}
|
||||
candidate := candidates[0]
|
||||
scope := candidate.scope
|
||||
if candidate.whole && relation.Type == "约束" {
|
||||
if !strings.Contains(relation.Evidence, content[scope.start:scope.end]) {
|
||||
return nil, true, fmt.Errorf("%w: complete restriction constraint requires unchanged heading and list evidence in source section %d", ErrGraphNeedsReview, scope.section)
|
||||
}
|
||||
return []string{graphComparisonText(content[scope.start:scope.end])}, true, nil
|
||||
}
|
||||
if scope.ambiguous || candidate.member < 0 {
|
||||
return nil, true, fmt.Errorf("%w: ambiguous restriction list in source section %d requires its complete literal block as a constraint, not an inferred population edge", ErrGraphNeedsReview, scope.section)
|
||||
}
|
||||
member := scope.members[candidate.member]
|
||||
var failures []error
|
||||
if !graphRestrictionTypeMatches(member.label, relation.Type) {
|
||||
failures = append(failures, fmt.Errorf("%w: restriction strength mismatch in source section %d item %d: expected %s from its own predicate, not %s from a neighboring item", ErrGraphNeedsReview, scope.section, candidate.member+1, member.label, relation.Type))
|
||||
}
|
||||
claim := content[scope.start:member.end]
|
||||
clause := scope.heading + content[member.start:member.end]
|
||||
if member.explicit {
|
||||
claim = content[member.start:member.end]
|
||||
clause = claim
|
||||
}
|
||||
if !strings.Contains(relation.Evidence, claim) {
|
||||
failures = append(failures, fmt.Errorf("%w: restriction evidence must include the unchanged governing predicate and target in source section %d item %d; a heading-governed item needs its heading and intervening text", ErrGraphNeedsReview, scope.section, candidate.member+1))
|
||||
}
|
||||
return []string{graphComparisonText(clause)}, true, errors.Join(failures...)
|
||||
}
|
||||
|
||||
func validateGraphRestrictionCoverage(graph *types.GraphData, content string) error {
|
||||
scopes := graphRestrictionScopes(content)
|
||||
var failures []error
|
||||
for _, scope := range scopes {
|
||||
covered := make([]bool, len(scope.members))
|
||||
complete := false
|
||||
for _, relation := range graph.Relation {
|
||||
if relation == nil || relation.Evidence == "" || !strings.Contains(content, relation.Evidence) ||
|
||||
!graphRestrictionEvidenceTouches(scope, relation.Evidence, content) {
|
||||
continue
|
||||
}
|
||||
_, scoped, err := graphRestrictionRelation(content, relation, scopes)
|
||||
if !scoped || err != nil || !graphRestrictionSubjectMatches(scope, relation.Node1) {
|
||||
continue
|
||||
}
|
||||
if relation.Type == "约束" && graphRestrictionCompleteTargetMatches(scope, relation.Node2, content) &&
|
||||
strings.Contains(relation.Evidence, content[scope.start:scope.end]) {
|
||||
complete = true
|
||||
break
|
||||
}
|
||||
for i, member := range scope.members {
|
||||
if graphRestrictionTargetMatches(member, strings.TrimSpace(relation.Node2)) && graphRestrictionTypeMatches(member.label, relation.Type) {
|
||||
covered[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !complete {
|
||||
complete = !scope.ambiguous
|
||||
for _, present := range covered {
|
||||
complete = complete && present
|
||||
}
|
||||
}
|
||||
if !complete {
|
||||
failures = append(failures, fmt.Errorf("%w: restriction list in source section %d is not fully covered; preserve each unambiguous member with its own strength or the complete literal heading and list as one constraint", ErrGraphNeedsReview, scope.section))
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
func validateGraphRestrictionAttributes(graph *types.GraphData, content string) error {
|
||||
scopes := graphRestrictionScopes(content)
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
sectionCount := len(graphProductSections(content))
|
||||
var failures []error
|
||||
for i, node := range graph.Node {
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
_, headingNode := graphRestrictionHeadingLabel(node.Name)
|
||||
for j, attribute := range node.Attributes {
|
||||
heading, _, labelled := graphRestrictionDisplayParts(attribute)
|
||||
if !headingNode && !labelled {
|
||||
continue
|
||||
}
|
||||
name := graphRestrictionHeadingName(heading)
|
||||
if headingNode {
|
||||
name = graphRestrictionHeadingName(node.Name)
|
||||
}
|
||||
var candidates []graphRestrictionScope
|
||||
for _, scope := range scopes {
|
||||
if graphRestrictionHeadingName(scope.heading) != name {
|
||||
continue
|
||||
}
|
||||
// Attributes have no evidence anchor to disambiguate a generic
|
||||
// product name across sections or a repeated standalone heading.
|
||||
if headingNode || (graphRestrictionSubjectMatches(scope, node.Name) &&
|
||||
(!graphGenericProduct(node.Name) || sectionCount == 1)) {
|
||||
candidates = append(candidates, scope)
|
||||
}
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
failures = append(failures, fmt.Errorf("%w: entity %d attribute %d has an ambiguous or unsupported restriction product/heading scope; require one literal local source block", ErrGraphNeedsReview, i+1, j+1))
|
||||
continue
|
||||
}
|
||||
scope := candidates[0]
|
||||
supported := graphRestrictionCompleteTargetMatches(scope, attribute, content)
|
||||
if headingNode && !labelled {
|
||||
matches := 0
|
||||
for _, member := range scope.members {
|
||||
if graphRestrictionDisplayText(attribute) != "" &&
|
||||
graphRestrictionDisplayText(attribute) == graphRestrictionDisplayText(content[member.start:member.end]) {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
supported = matches == 1
|
||||
}
|
||||
if !supported {
|
||||
failures = append(failures, fmt.Errorf("%w: entity %d attribute %d has unsupported restriction text in source section %d; preserve literal OCR spelling, each local predicate and the complete labelled list", ErrGraphNeedsReview, i+1, j+1, scope.section))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestGraphRestrictionCompleteDisplayKeepsContentAndEvidence(t *testing.T) {
|
||||
const block = "〖 不适宜人群 〕 对甲类慎用 、 乙丿 IJ 类 、 丙类(限成人)"
|
||||
source := "产品名称:甲品\n" + block + "。"
|
||||
for _, tc := range []struct {
|
||||
name, target string
|
||||
wantErr bool
|
||||
}{
|
||||
{"unchanged block", block, false},
|
||||
{"colon display", "不适宜人群:对甲类慎用、乙丿 IJ 类、丙类(限成人)", false},
|
||||
{"ascii colon and whitespace", " 不适 宜人群 :\n对甲类慎用 、 乙丿\tIJ 类 、 丙类(限成人) ", false},
|
||||
{"source brackets with whitespace", "〖不适宜人群〕对甲类慎用、乙丿IJ类、丙类(限成人)", false},
|
||||
{"different bracket", strings.Replace(block, "〖", "〔", 1), true},
|
||||
{"unpaired bracket", strings.TrimPrefix(block, "〖"), true},
|
||||
{"OCR correction", strings.Replace(block, "乙丿 IJ 类", "乙儿童类", 1), true},
|
||||
{"OCR compatibility replacement", strings.Replace(block, "不适宜", "不适宜", 1), true},
|
||||
{"case folding", strings.Replace(block, "IJ", "ij", 1), true},
|
||||
{"member omitted", strings.Replace(block, "乙丿 IJ 类 、 ", "", 1), true},
|
||||
{"members reordered", "不适宜人群:乙丿 IJ 类、对甲类慎用、丙类(限成人)", true},
|
||||
{"body delimiter changed", strings.ReplaceAll(block, "、", ","), true},
|
||||
{"predicate strengthened", strings.Replace(block, "慎用", "禁用", 1), true},
|
||||
{"predicate dropped", strings.Replace(block, "慎用", "", 1), true},
|
||||
{"heading weakened", strings.Replace(block, "不适宜", "慎用", 1), true},
|
||||
{"qualifier dropped", strings.Replace(block, "(限成人)", "", 1), true},
|
||||
{"generic label", "不适宜人群", true},
|
||||
{"empty label", "", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
relation := &types.GraphRelation{Node1: "甲品", Node2: tc.target, Type: "约束", Evidence: block}
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{relation}}
|
||||
err := validateGraphRelationEvidence(graph, source)
|
||||
coverageErr := validateGraphRestrictionCoverage(graph, source)
|
||||
if (err != nil) != tc.wantErr || (coverageErr != nil) != tc.wantErr {
|
||||
t.Fatalf("validation = %v, coverage = %v, want rejection %v", err, coverageErr, tc.wantErr)
|
||||
}
|
||||
if relation.Node2 != tc.target || relation.Evidence != block {
|
||||
t.Fatal("display comparison mutated the model output")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, evidence := range []string{
|
||||
strings.Replace(block, "〖", "〔", 1),
|
||||
graphRestrictionDisplayText(block),
|
||||
"对甲类慎用",
|
||||
} {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: "不适宜人群:对甲类慎用、乙丿 IJ 类、丙类(限成人)", Type: "约束", Evidence: evidence}}}
|
||||
if err := validateGraphRelationEvidence(graph, source); err == nil {
|
||||
t.Fatal("display equivalence waived exact complete evidence")
|
||||
}
|
||||
if err := validateGraphRestrictionCoverage(graph, source); !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatal("incomplete or changed evidence satisfied coverage")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionAttributesAreLiteralAndSourceScoped(t *testing.T) {
|
||||
const block = "〔 不适宜人群 〕 对甲类慎用 、 乙丿 IJ 类 、 丙类"
|
||||
source := "产品名称:甲品\n" + block + "。"
|
||||
for _, tc := range []struct {
|
||||
name, node, attribute string
|
||||
wantErr bool
|
||||
}{
|
||||
{"source block", "甲品", block, false},
|
||||
{"label display", "甲品", "不适宜人群:对甲类慎用、乙丿 IJ 类、丙类", false},
|
||||
{"local generic product", "本品", block, false},
|
||||
{"ordinary descriptive attribute", "甲品", "普通说明", false},
|
||||
{"heading member", "不适宜人群", "乙丿 IJ 类", false},
|
||||
{"heading local predicate", "不适宜人群", "对甲类慎用", false},
|
||||
{"heading member whitespace", "不适宜人群", "乙丿IJ类", false},
|
||||
{"heading full list", "不适宜人群", block, false},
|
||||
{"product OCR correction", "甲品", "不适宜人群:对甲类慎用、乙儿童类、丙类", true},
|
||||
{"standalone OCR correction", "不适宜人群", "乙儿童类", true},
|
||||
{"case changed", "不适宜人群", "乙丿 ij 类", true},
|
||||
{"standalone dropped predicate", "不适宜人群", "甲类", true},
|
||||
{"standalone changed predicate", "不适宜人群", "对甲类禁用", true},
|
||||
{"standalone fabricated predicate", "不适宜人群", "丙类慎用", true},
|
||||
{"changed heading strength", "甲品", "慎用人群:对甲类慎用、乙丿 IJ 类、丙类", true},
|
||||
{"changed heading entity", "慎用人群", "丙类", true},
|
||||
{"product dropped member", "甲品", "不适宜人群:对甲类慎用、乙丿 IJ 类", true},
|
||||
{"product reordered members", "甲品", "不适宜人群:对甲类慎用、丙类、乙丿 IJ 类", true},
|
||||
{"product changed delimiter", "甲品", "不适宜人群:对甲类慎用,乙丿 IJ 类,丙类", true},
|
||||
{"unknown owner", "乙品", block, true},
|
||||
{"list member is not owner", "丙类", block, true},
|
||||
{"standalone empty member", "不适宜人群", "", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
node := &types.GraphNode{Name: tc.node, Attributes: []string{tc.attribute}}
|
||||
err := validateGraphRestrictionAttributes(&types.GraphData{Node: []*types.GraphNode{node}}, source)
|
||||
if (err != nil) != tc.wantErr || (tc.wantErr && (!errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), "entity 1 attribute 1"))) {
|
||||
t.Fatalf("validation = %v, want indexed rejection %v", err, tc.wantErr)
|
||||
}
|
||||
if node.Name != tc.node || node.Attributes[0] != tc.attribute {
|
||||
t.Fatal("attribute validation rewrote an entity")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionAttributesCannotBorrowAnotherProductBlock(t *testing.T) {
|
||||
const first = "〔不适宜人群〕甲类、乙类"
|
||||
const second = "〔不适宜人群〕丙类、丁类"
|
||||
source := "产品名称:甲品\n" + first + "。\n\n产品名称:乙品\n" + second + "。"
|
||||
for _, tc := range []struct {
|
||||
node, attribute string
|
||||
wantErr bool
|
||||
}{
|
||||
{"甲品", first, false},
|
||||
{"乙品", second, false},
|
||||
{"甲品", second, true},
|
||||
{"乙品", first, true},
|
||||
{"本品", first, true},
|
||||
{"本品(前段未名产品)", second, true},
|
||||
{"不适宜人群", "甲类", true},
|
||||
{"不适宜人群", first, true},
|
||||
} {
|
||||
graph := &types.GraphData{Node: []*types.GraphNode{{Name: tc.node, Attributes: []string{tc.attribute}}}}
|
||||
if err := validateGraphRestrictionAttributes(graph, source); (err != nil) != tc.wantErr {
|
||||
t.Fatalf("node=%s, error=%v, want rejection %v", tc.node, err, tc.wantErr)
|
||||
}
|
||||
}
|
||||
// A second product does not need its own list to make an evidence-free
|
||||
// generic product attribute ambiguous.
|
||||
source = "产品名称:甲品\n" + first + "。\n\n产品名称:乙品\n普通说明。"
|
||||
graph := &types.GraphData{Node: []*types.GraphNode{{Name: "本品", Attributes: []string{first}}}}
|
||||
if err := validateGraphRestrictionAttributes(graph, source); !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatal("generic attribute borrowed the only list from an ambiguous product")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionConstraintCannotMaskDuplicateAttributeErrors(t *testing.T) {
|
||||
const block = "〔不适宜人群〕对甲类慎用、乙丿 IJ 类"
|
||||
source := "产品名称:甲品\n" + block + "。"
|
||||
graph := &types.GraphData{
|
||||
Node: []*types.GraphNode{
|
||||
{Name: "甲品", Attributes: []string{"普通说明", "不适宜人群:对甲类慎用、乙儿童类"}},
|
||||
{Name: "不适宜人群", Attributes: []string{"对甲类慎用", "乙儿童类"}},
|
||||
},
|
||||
Relation: []*types.GraphRelation{{Node1: "甲品", Node2: "不适宜人群:对甲类慎用、乙丿 IJ 类", Type: "约束", Evidence: block}},
|
||||
}
|
||||
response := graphCoverageResponse(t, graph)
|
||||
accepted, err := NewFormater().parseGraph(context.Background(), response.Content, &source)
|
||||
if accepted != nil || !errors.Is(err, ErrGraphNeedsReview) ||
|
||||
!strings.Contains(err.Error(), "entity 1 attribute 2") || !strings.Contains(err.Error(), "entity 2 attribute 2") ||
|
||||
strings.Count(err.Error(), "unsupported restriction text") != 2 || strings.Contains(err.Error(), "relation object") {
|
||||
t.Fatalf("complete constraint concealed duplicate unsupported attributes: %v", err)
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// Private artifacts are read only. Changes below create separate in-memory
|
||||
// regression candidates, never a repaired saved response or production graph.
|
||||
func TestGraphRestrictionPrivateReplayKeepsQuoteAndStrengthRejection(t *testing.T) {
|
||||
path := os.Getenv("GRAPH_RESTRICTION_REPLAY_REPORT")
|
||||
if path == "" {
|
||||
t.Skip("private restriction replay report not configured")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal("cannot read private restriction replay report")
|
||||
}
|
||||
digest := func(value []byte) string {
|
||||
hash := sha256.Sum256(value)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
const reportSHA = "e6ef1e6affdb038c8d7424a3903441658fab0f40a49ffdfa76efc0d31b35919a"
|
||||
var report struct {
|
||||
ChunkID string `json:"chunk_id"`
|
||||
Source string `json:"source_content"`
|
||||
SourceSHA256 string `json:"source_sha256"`
|
||||
RejectedContent string `json:"rejected_model_content"`
|
||||
ProductionWrite bool `json:"production_write"`
|
||||
Passed bool `json:"passed"`
|
||||
}
|
||||
if digest(raw) != reportSHA || json.Unmarshal(raw, &report) != nil ||
|
||||
report.ChunkID != "e4261987-382b-4e7a-8a72-3e741dcfb75f" ||
|
||||
report.SourceSHA256 != "6004588d8162144f3465bec858b74759d40c1af1ba4f98b99e5c1ef990172a02" ||
|
||||
digest([]byte(report.Source)) != report.SourceSHA256 ||
|
||||
digest([]byte(report.RejectedContent)) != "3148070f609d5475d70c5be184fb03a9487c7d24aa530680c64bc31c1cfacf39" ||
|
||||
report.ProductionWrite || report.Passed {
|
||||
t.Fatal("private restriction evidence differs from the reviewed rejected candidate")
|
||||
}
|
||||
formatter := NewFormater()
|
||||
graph, err := formatter.parseGraph(context.Background(), report.RejectedContent, &report.Source)
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) ||
|
||||
strings.Count(err.Error(), "requires exact evidence") != 4 ||
|
||||
strings.Count(err.Error(), "restriction strength mismatch") != 3 {
|
||||
t.Fatal("original private replay did not independently reject all reviewed quote and strength failures")
|
||||
}
|
||||
items, err := formatter.parseOutput(context.Background(), report.RejectedContent)
|
||||
if err != nil {
|
||||
t.Fatal("cannot decode private response")
|
||||
}
|
||||
relationIndex, quoteChanges := 0, 0
|
||||
for _, item := range items {
|
||||
if item["entity1"] == nil {
|
||||
continue
|
||||
}
|
||||
if relationIndex >= 3 && relationIndex <= 6 {
|
||||
quote, ok := item["evidence"].(string)
|
||||
if !ok || !strings.HasPrefix(quote, "(") || strings.Contains(report.Source, quote) {
|
||||
t.Fatal("reviewed quote mutation changed shape")
|
||||
}
|
||||
quote = "〔" + strings.TrimPrefix(quote, "(")
|
||||
if !strings.Contains(report.Source, quote) {
|
||||
t.Fatal("reviewed delimiter-only test candidate is not source-exact")
|
||||
}
|
||||
item["evidence"] = quote
|
||||
quoteChanges++
|
||||
}
|
||||
relationIndex++
|
||||
}
|
||||
if quoteChanges != 4 || relationIndex != 8 {
|
||||
t.Fatal("reviewed response relation shape changed")
|
||||
}
|
||||
quoteOnly, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatal("cannot encode offline quote-only test candidate")
|
||||
}
|
||||
graph, err = formatter.parseGraph(context.Background(), string(quoteOnly), &report.Source)
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) ||
|
||||
strings.Contains(err.Error(), "requires exact evidence") ||
|
||||
strings.Count(err.Error(), "restriction strength mismatch") != 3 {
|
||||
t.Fatal("delimiter-only offline candidate hid the three strength errors")
|
||||
}
|
||||
relationIndex = 0
|
||||
for _, item := range items {
|
||||
if item["entity1"] == nil {
|
||||
continue
|
||||
}
|
||||
if relationIndex >= 4 && relationIndex <= 6 {
|
||||
item["relation"] = "不适用于"
|
||||
}
|
||||
relationIndex++
|
||||
}
|
||||
corrected, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatal("cannot encode offline scoped test candidate")
|
||||
}
|
||||
graph, err = formatter.parseGraph(context.Background(), string(corrected), &report.Source)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 8 {
|
||||
var categories []string
|
||||
if err != nil {
|
||||
for _, category := range []string{"restriction", "strength", "scope", "prohibition", "caution", "condition", "storage", "quantity", "direction", "identity", "price", "product", "negative", "source evidence", "supported", "unsupported", "missing", "exact evidence"} {
|
||||
if strings.Contains(err.Error(), category) {
|
||||
categories = append(categories, category)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("source-exact scoped offline candidate failed validation (categories=%v)", categories)
|
||||
}
|
||||
caution, unsuitable, constraints, attributes := 0, 0, 0, 0
|
||||
for _, relation := range graph.Relation {
|
||||
if !strings.Contains(report.Source, relation.Evidence) {
|
||||
t.Fatal("offline scoped candidate changed evidence")
|
||||
}
|
||||
switch relation.Type {
|
||||
case "慎用于":
|
||||
caution++
|
||||
case "不适用于":
|
||||
unsuitable++
|
||||
case "约束":
|
||||
constraints++
|
||||
}
|
||||
}
|
||||
for _, node := range graph.Node {
|
||||
attributes += len(node.Attributes)
|
||||
}
|
||||
if caution != 1 || unsuitable != 3 || constraints != 4 || attributes != 10 {
|
||||
t.Fatal("offline scoped correction lost reviewed assertions or attributes")
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil || digest(after) != reportSHA {
|
||||
t.Fatal("private replay artifact changed during validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionPrivateDisplayReplayKeepsAttributeRejection(t *testing.T) {
|
||||
path := os.Getenv("GRAPH_RESTRICTION_DISPLAY_REPLAY_REPORT")
|
||||
if path == "" {
|
||||
t.Skip("private restriction display replay report not configured")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal("cannot read private display replay report")
|
||||
}
|
||||
digest := func(value []byte) string {
|
||||
hash := sha256.Sum256(value)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
const reportSHA = "956548002116c2c50d05255e533f99d5911d97dbd7c44d8bbf1c21354e9b5f15"
|
||||
var report struct {
|
||||
ChunkID string `json:"chunk_id"`
|
||||
Source string `json:"source_content"`
|
||||
SourceSHA256 string `json:"source_sha256"`
|
||||
RejectedContent string `json:"rejected_model_content"`
|
||||
ProductionWrite bool `json:"production_write"`
|
||||
Passed bool `json:"passed"`
|
||||
}
|
||||
if digest(raw) != reportSHA || json.Unmarshal(raw, &report) != nil ||
|
||||
report.ChunkID != "e4261987-382b-4e7a-8a72-3e741dcfb75f" ||
|
||||
report.SourceSHA256 != "6004588d8162144f3465bec858b74759d40c1af1ba4f98b99e5c1ef990172a02" ||
|
||||
digest([]byte(report.Source)) != report.SourceSHA256 ||
|
||||
digest([]byte(report.RejectedContent)) != "4bdde301dedded23f38c6e999642ea8652e1c0ef0554580b1a760bccbebe8dba" ||
|
||||
report.ProductionWrite || report.Passed {
|
||||
t.Fatal("private display evidence differs from the reviewed rejected response")
|
||||
}
|
||||
formatter := NewFormater()
|
||||
graph, err := formatter.parseGraph(context.Background(), report.RejectedContent, &report.Source)
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) ||
|
||||
!strings.Contains(err.Error(), "entity 2 attribute 3") ||
|
||||
!strings.Contains(err.Error(), "entity 3 attribute 2") ||
|
||||
strings.Count(err.Error(), "unsupported restriction text") != 2 ||
|
||||
strings.Contains(err.Error(), "relation object") || strings.Contains(err.Error(), "not fully covered") {
|
||||
t.Fatal("immutable response must reject both reviewed attributes while accepting the supported display relation")
|
||||
}
|
||||
items, err := formatter.parseOutput(context.Background(), report.RejectedContent)
|
||||
if err != nil || len(items) != 7 {
|
||||
t.Fatal("private display response changed shape")
|
||||
}
|
||||
last := items[6]
|
||||
relation := &types.GraphRelation{
|
||||
Node1: last["entity1"].(string), Node2: last["entity2"].(string),
|
||||
Type: last["relation"].(string), Evidence: last["evidence"].(string),
|
||||
}
|
||||
before := *relation
|
||||
isolated := &types.GraphData{Relation: []*types.GraphRelation{relation}}
|
||||
if err := errors.Join(validateGraphRelationEvidence(isolated, report.Source), validateGraphRestrictionCoverage(isolated, report.Source)); err != nil || *relation != before {
|
||||
t.Fatal("isolated supported display relation was rejected or rewritten")
|
||||
}
|
||||
const usageTarget = "每日1瓶,开瓶即饮"
|
||||
const usageEvidence = "每日 1 瓶 , 开瓶即饮"
|
||||
if !strings.Contains(report.Source, usageEvidence) {
|
||||
t.Fatal("reviewed usage source changed")
|
||||
}
|
||||
for _, item := range items {
|
||||
if target, ok := item["entity2"].(string); ok && strings.Contains(graphRestrictionDisplayText(target), "开瓶即饮") {
|
||||
t.Fatal("saved response no longer has the reviewed usage-constraint omission")
|
||||
}
|
||||
}
|
||||
// This is a separate full-review candidate, not a repair of the saved
|
||||
// response. Both unsupported attributes and missing usage must be handled.
|
||||
changed := 0
|
||||
for _, item := range items {
|
||||
attributes, _ := item["entity_attributes"].([]interface{})
|
||||
for i, attribute := range attributes {
|
||||
text, ok := attribute.(string)
|
||||
if ok && strings.Contains(text, "婴幼儿") {
|
||||
attributes[i] = strings.ReplaceAll(text, "婴幼儿", "婴幼丿 IJ")
|
||||
changed++
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed != 2 {
|
||||
t.Fatal("reviewed duplicate attribute shape changed")
|
||||
}
|
||||
items = append(items, map[string]interface{}{
|
||||
"entity1": relation.Node1, "entity2": usageTarget, "relation": "约束", "evidence": usageEvidence,
|
||||
})
|
||||
candidate, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatal("cannot encode separate full-review candidate")
|
||||
}
|
||||
graph, err = formatter.parseGraph(context.Background(), string(candidate), &report.Source)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 5 {
|
||||
t.Fatal("source-grounded full-review candidate failed validation")
|
||||
}
|
||||
attributes, usage, completeList := 0, 0, 0
|
||||
for _, node := range graph.Node {
|
||||
attributes += len(node.Attributes)
|
||||
}
|
||||
for _, edge := range graph.Relation {
|
||||
if edge.Type != "约束" || !strings.Contains(report.Source, edge.Evidence) {
|
||||
t.Fatal("full-review candidate changed source evidence or relation type")
|
||||
}
|
||||
if edge.Node2 == usageTarget && edge.Evidence == usageEvidence && edge.Node1 == relation.Node1 {
|
||||
usage++
|
||||
}
|
||||
if *edge == before {
|
||||
completeList++
|
||||
}
|
||||
}
|
||||
if attributes != 13 || usage != 1 || completeList != 1 {
|
||||
t.Fatal("full-review candidate lost attributes, usage or the unchanged display relation")
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil || digest(after) != reportSHA {
|
||||
t.Fatal("immutable private display artifact changed during replay")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
const graphRestrictionTestBlock = "〔 不适宜人群 〕 对甲类慎用 、 乙类 、 丙类"
|
||||
const graphRestrictionTestSource = "产品名称:甲品\n" + graphRestrictionTestBlock + "。"
|
||||
|
||||
func TestGraphRestrictionStrengthBelongsToItsOwnItem(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, target, label, evidence, wantErr string
|
||||
}{
|
||||
{"local caution", "甲类", "慎用于", "对甲类慎用", ""},
|
||||
{"local caution preserves source preposition", "对甲类", "慎用于", "对甲类慎用", ""},
|
||||
{"local caution with full list evidence", "甲类", "慎用于", graphRestrictionTestBlock, ""},
|
||||
{"first inherited non suitability", "乙类", "不适用于", graphRestrictionTestBlock, ""},
|
||||
{"last inherited non suitability", "丙类", "不适用于", graphRestrictionTestBlock, ""},
|
||||
{"neighbor caution cannot propagate", "乙类", "慎用于", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"later neighbor caution cannot propagate", "丙类", "慎用于", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"caution cannot strengthen to non suitability", "甲类", "不适用于", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"caution cannot strengthen to prohibition", "甲类", "禁用于", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"non suitability cannot strengthen to prohibition", "乙类", "禁用于", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"permission cannot replace restriction", "乙类", "适用于", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"association cannot replace restriction", "乙类", "关联", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"heading predicate cannot be cropped", "乙类", "不适用于", "乙类", "governing predicate"},
|
||||
{"local predicate cannot be cropped", "甲类", "慎用于", "甲类", "governing predicate"},
|
||||
{"whole source constraint", graphRestrictionTestBlock, "约束", graphRestrictionTestBlock, ""},
|
||||
{"truncated constraint", "乙类", "约束", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"changed target spelling", "乙组", "不适用于", graphRestrictionTestBlock, "ambiguous restriction list"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: tc.target, Type: tc.label, Evidence: tc.evidence}}}
|
||||
err := validateGraphRelationEvidence(graph, graphRestrictionTestSource)
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if !errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want review containing %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionScopesRetainSourceSpans(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
block, target, label string
|
||||
}{
|
||||
{"【慎用人群】甲类,乙类", "乙类", "慎用于"},
|
||||
{"〖禁用对象〕甲类;乙类", "乙类", "禁用于"},
|
||||
{"[不适用人群] 丙类、乙类", "丙类", "不适用于"},
|
||||
{"不适宜人群:丙类;乙类", "丙类", "不适用于"},
|
||||
{"〔 不适宜人群 〕 对甲类慎甩,乙丿 IJ 类、丙类", "乙丿 IJ 类", "不适用于"},
|
||||
{"〔不适合对象〕甲类\n 、\t乙类", "乙类", "不适用于"},
|
||||
{"〔慎用人群〕甲类禁用、乙类", "甲类", "禁用于"},
|
||||
{"〔慎用人群〕甲类禁用、乙类", "乙类", "慎用于"},
|
||||
} {
|
||||
t.Run(tc.block, func(t *testing.T) {
|
||||
source := "产品名称:甲品\n" + tc.block + "\n〔贮存条件〕阴凉处保存。"
|
||||
scopes := graphRestrictionScopes(source)
|
||||
if len(scopes) != 1 || source[scopes[0].start:scopes[0].end] != tc.block {
|
||||
t.Fatal("restriction list boundaries changed source text")
|
||||
}
|
||||
relation := &types.GraphRelation{Node1: "甲品", Node2: tc.target, Type: tc.label, Evidence: tc.block}
|
||||
if err := validateGraphRelationEvidence(&types.GraphData{Relation: []*types.GraphRelation{relation}}, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if relation.Evidence != tc.block || relation.Node2 != tc.target {
|
||||
t.Fatal("validation mutated source text or target")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionAmbiguityRequiresCompleteLiteralBlock(t *testing.T) {
|
||||
for _, block := range []string{
|
||||
"〔不适宜人群〕甲类、乙类慎用",
|
||||
"〔不适宜人群〕甲类、甲类",
|
||||
"〔不适宜人群〕甲类和乙类慎用",
|
||||
"〔不适宜人群〕甲类慎甪、乙类",
|
||||
"〔不适冝人群〕甲类、乙类",
|
||||
"〔不适宜人群〕甲类(特殊条件除外)、乙类",
|
||||
} {
|
||||
t.Run(block, func(t *testing.T) {
|
||||
source := "本品\n" + block + "。"
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "本品", Node2: "甲类", Type: "不适用于", Evidence: block}}}
|
||||
if err := validateGraphRelationEvidence(graph, source); !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatalf("ambiguous list accepted: %v", err)
|
||||
}
|
||||
graph.Relation[0].Node2, graph.Relation[0].Type = block, "约束"
|
||||
if err := validateGraphRelationEvidence(graph, source); err != nil {
|
||||
t.Fatal("literal constraint rejected", err)
|
||||
}
|
||||
if err := validateGraphRestrictionCoverage(graph, source); err != nil {
|
||||
t.Fatal("complete literal constraint lost coverage", err)
|
||||
}
|
||||
graph.Relation[0].Node2 = strings.TrimPrefix(block, "〔")
|
||||
if err := validateGraphRestrictionCoverage(graph, source); !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatal("rewritten target satisfied full-block coverage")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionCoverageCannotDropOrHideMembers(t *testing.T) {
|
||||
valid := []*types.GraphRelation{
|
||||
{Node1: "甲品", Node2: "甲类", Type: "慎用于", Evidence: "对甲类慎用"},
|
||||
{Node1: "甲品", Node2: "乙类", Type: "不适用于", Evidence: graphRestrictionTestBlock},
|
||||
{Node1: "甲品", Node2: "丙类", Type: "不适用于", Evidence: graphRestrictionTestBlock},
|
||||
}
|
||||
if err := validateGraphRestrictionCoverage(&types.GraphData{Relation: valid}, graphRestrictionTestSource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid[0].Node2 = "对甲类"
|
||||
if err := validateGraphRestrictionCoverage(&types.GraphData{Relation: valid}, graphRestrictionTestSource); err != nil {
|
||||
t.Fatal("literal source preposition lost coverage", err)
|
||||
}
|
||||
valid[0].Node2 = "甲类"
|
||||
for missing := range valid {
|
||||
relations := append([]*types.GraphRelation{}, valid[:missing]...)
|
||||
relations = append(relations, valid[missing+1:]...)
|
||||
graph := &types.GraphData{Node: []*types.GraphNode{{Name: "甲品", Attributes: []string{graphRestrictionTestBlock}}}, Relation: relations}
|
||||
if err := validateGraphRestrictionCoverage(graph, graphRestrictionTestSource); !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatal("attributes or neighboring edges concealed a missing list member")
|
||||
}
|
||||
}
|
||||
const storage = "阴凉处保存"
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: storage, Type: "约束", Evidence: storage}}}
|
||||
response := graphCoverageResponse(t, graph)
|
||||
source := graphRestrictionTestSource + "\n" + storage + "。"
|
||||
if accepted, err := NewFormater().parseGraph(context.Background(), response.Content, &source); accepted != nil || !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatal("unrelated valid relation allowed restriction omission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionEvidenceAndStrengthErrorsShareOneCorrection(t *testing.T) {
|
||||
badQuote := strings.Replace(graphRestrictionTestBlock, "〔", "(", 1)
|
||||
bad := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: "乙类", Type: "慎用于", Evidence: badQuote}}}
|
||||
model := &extractionResponseChat{response: graphCoverageResponse(t, bad)}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), graphRestrictionTestSource)
|
||||
if graph != nil || err == nil || model.calls != 2 || model.options.MaxTokens != 4096 || model.options.Temperature != 0 {
|
||||
t.Fatal("invalid response escaped bounded correction")
|
||||
}
|
||||
for _, message := range []string{err.Error(), model.messages[3].Content} {
|
||||
if !strings.Contains(message, "requires exact evidence") || !strings.Contains(message, "restriction strength mismatch") {
|
||||
t.Fatal("quote error hid independently anchored restriction strength")
|
||||
}
|
||||
}
|
||||
if bad.Relation[0].Evidence != badQuote || bad.Relation[0].Type != "慎用于" {
|
||||
t.Fatal("invalid model output was silently repaired")
|
||||
}
|
||||
if !strings.Contains(model.messages[0].Content, "A heading that governs a list supplies its predicate") ||
|
||||
strings.Contains(model.messages[0].Content, "section titles need not be included") {
|
||||
t.Fatal("persisted prompt still allows predicate headings to disappear")
|
||||
}
|
||||
if !strings.Contains(model.messages[0].Content, "literal OCR spelling in restriction targets, entity names and attributes") ||
|
||||
!strings.Contains(model.messages[0].Content, "Preserve supported usage procedures") ||
|
||||
!strings.Contains(model.messages[3].Content, "限制实体名称和属性也必须保留原文 OCR 字形") ||
|
||||
!strings.Contains(model.messages[3].Content, "保留有据的用量及使用步骤约束关系") {
|
||||
t.Fatal("correction omitted attribute grounding or usage preservation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionCorrectionMustExplicitlyPreserveCompleteSource(t *testing.T) {
|
||||
bad := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: "乙类", Type: "慎用于", Evidence: strings.Replace(graphRestrictionTestBlock, "〔", "(", 1)}}}
|
||||
good := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: graphRestrictionTestBlock, Type: "约束", Evidence: graphRestrictionTestBlock}}}
|
||||
model := &extractionResponseChat{responses: []*types.ChatResponse{graphCoverageResponse(t, bad), graphCoverageResponse(t, good)}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), graphRestrictionTestSource)
|
||||
if err != nil || graph == nil || model.calls != 2 || len(graph.Relation) != 1 ||
|
||||
graph.Relation[0].Type != "约束" || graph.Relation[0].Node2 != graphRestrictionTestBlock || graph.Relation[0].Evidence != graphRestrictionTestBlock {
|
||||
t.Fatal("explicit complete source constraint was not preserved by bounded correction")
|
||||
}
|
||||
good.Relation = append(good.Relation, &types.GraphRelation{Node1: "甲品", Node2: "乙类", Type: "慎用于", Evidence: graphRestrictionTestBlock})
|
||||
response := graphCoverageResponse(t, good)
|
||||
source := graphRestrictionTestSource
|
||||
if accepted, err := NewFormater().parseGraph(context.Background(), response.Content, &source); accepted != nil ||
|
||||
!errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), "restriction strength mismatch") {
|
||||
t.Fatal("full constraint concealed a contradictory typed edge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionQuotationMutationsStayRejected(t *testing.T) {
|
||||
const block = "〖 不适宜人群 〕 甲丿 IJ 类\n 、\t乙类"
|
||||
source := "本品\n" + block + "。"
|
||||
for _, quote := range []string{
|
||||
strings.Replace(block, "〖", "〔", 1),
|
||||
strings.Replace(block, "甲丿 IJ 类", "甲儿童类", 1),
|
||||
strings.ReplaceAll(block, " ", ""),
|
||||
strings.ReplaceAll(block, "\n", `\n`),
|
||||
strings.ReplaceAll(block, "\t", " "),
|
||||
} {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "本品", Node2: "乙类", Type: "不适用于", Evidence: quote}}}
|
||||
if err := validateGraphRelationEvidence(graph, source); err == nil || !strings.Contains(err.Error(), "requires exact evidence") {
|
||||
t.Fatal("mutated source quotation accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionScopeCannotCrossProducts(t *testing.T) {
|
||||
const first = "〔不适宜人群〕甲类、乙类"
|
||||
const second = "〔慎用人群〕甲类、乙类"
|
||||
source := "产品名称:甲品\n" + first + "。\n\n产品名称:乙品\n" + second + "。"
|
||||
for _, tc := range []struct {
|
||||
subject, quote, label string
|
||||
wantErr bool
|
||||
}{
|
||||
{"甲品", first, "不适用于", false},
|
||||
{"乙品", second, "慎用于", false},
|
||||
{"甲品", second, "慎用于", true},
|
||||
{"乙品", first, "不适用于", true},
|
||||
{"本品", "甲类", "慎用于", true},
|
||||
{"未出现产品", first, "不适用于", true},
|
||||
{"乙类", first, "不适用于", true},
|
||||
} {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: tc.subject, Node2: "甲类", Type: tc.label, Evidence: tc.quote}}}
|
||||
err := validateGraphRelationEvidence(graph, source)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("subject=%s, label=%s, error=%v", tc.subject, tc.label, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionListsDoNotChangeQueryOrNarrativeExtraction(t *testing.T) {
|
||||
const source = "小说章节〔不适宜人群〕甲类、乙类。"
|
||||
if len(graphRestrictionScopes(source)) != 0 {
|
||||
t.Fatal("non-product narrative became a product restriction list")
|
||||
}
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: `[{"entity":"甲品"}]`, FinishReason: "stop"}}
|
||||
extractor := NewExtractor(model, &types.PromptTemplateStructured{Description: "Extract query entities"})
|
||||
graph, err := extractor.Extract(context.Background(), graphRestrictionTestSource)
|
||||
if err != nil || graph == nil || model.calls != 1 {
|
||||
t.Fatal("query extraction acquired persisted restriction coverage")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
const graphScopeSource = "不得用于预防疾病。\n〔 贮存方法 〗 避光 、 密封保存 。\n\n## 工作表1\n\n| 品牌名称 | 产品名称 | 终端零售价 |\n| --- | --- | --- |\n| 甲品牌 | 乙产品 | 139元/一瓶 |\n"
|
||||
|
||||
func TestGraphProductSourceSectionsReconstructExactInput(t *testing.T) {
|
||||
for _, source := range []string{graphScopeSource, "前段。\n\n产品名称:甲\n正文\n\n产品名称:乙\n另段", "1丨甲\n内容\n2丨乙\n内容"} {
|
||||
var plan struct {
|
||||
Sections []struct {
|
||||
Number int `json:"section"`
|
||||
Text string `json:"text"`
|
||||
} `json:"source_sections"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(graphProductSourcePrompt(source)), &plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var reconstructed strings.Builder
|
||||
for i, section := range plan.Sections {
|
||||
if section.Number != i+1 {
|
||||
t.Fatal("nonsequential scope")
|
||||
}
|
||||
reconstructed.WriteString(section.Text)
|
||||
}
|
||||
if len(plan.Sections) < 2 || reconstructed.String() != source {
|
||||
t.Fatal("source changed or omitted")
|
||||
}
|
||||
}
|
||||
const ordinary = "Alice knows Bob."
|
||||
if graphProductSourcePrompt(ordinary) != ordinary {
|
||||
t.Fatal("ordinary source was rewritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphScopedCorrectionKeepsWarningsStorageAndPrices(t *testing.T) {
|
||||
response := []map[string]any{
|
||||
{"entity1": "乙产品", "entity2": "预防疾病", "relation": "不适用于", "evidence": "不得用于预防疾病"},
|
||||
{"entity1": "乙产品", "entity2": "避光密封保存", "relation": "约束", "evidence": "避光 、 密封保存"},
|
||||
{"entity": "甲品牌乙产品", "entity_attributes": []string{"终端零售价:139元/一瓶"}},
|
||||
}
|
||||
bad, _ := json.Marshal(response)
|
||||
response[0]["entity1"], response[1]["entity1"] = "本品", "本品"
|
||||
good, _ := json.Marshal(response)
|
||||
model := &extractionResponseChat{responses: []*types.ChatResponse{
|
||||
{Content: string(bad), FinishReason: "stop"}, {Content: string(good), FinishReason: "stop"},
|
||||
}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), graphScopeSource)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 2 || model.calls != 2 {
|
||||
t.Fatalf("bounded correction failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(model.messages[1].Content, `"source_sections"`) ||
|
||||
!strings.Contains(model.messages[3].Content, "evidence is in source sections [1]") ||
|
||||
!strings.Contains(model.messages[3].Content, "proposed subject occurs in sections [3]") {
|
||||
t.Fatal("correction did not identify the conflicting source scopes")
|
||||
}
|
||||
for _, relation := range graph.Relation {
|
||||
if relation.Node1 != "本品" || !strings.Contains(graphScopeSource, relation.Evidence) {
|
||||
t.Fatal("scope/evidence changed")
|
||||
}
|
||||
}
|
||||
if graph.Node[len(graph.Node)-1] == nil {
|
||||
t.Fatal("lost graph node")
|
||||
}
|
||||
priceFound := false
|
||||
for _, node := range graph.Node {
|
||||
for _, attribute := range node.Attributes {
|
||||
priceFound = priceFound || attribute == "终端零售价:139元/一瓶"
|
||||
}
|
||||
}
|
||||
if !priceFound {
|
||||
t.Fatal("correction dropped the supported table price")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphSourceEnvelopeCannotBecomeEvidence(t *testing.T) {
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: `[{"entity1":"本品","entity2":"预防疾病","relation":"不适用于","evidence":"source_sections"}]`, FinishReason: "stop"}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
if graph, err := extractor.Extract(context.Background(), graphScopeSource); err == nil || graph != nil || model.calls != 2 {
|
||||
t.Fatal("metadata was accepted as evidence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphSeriesHeadingCannotPromoteLocalWarnings(t *testing.T) {
|
||||
for _, title := range []string{"## 甲乙套盒系列介绍", "甲乙套盒系列"} {
|
||||
source := title + "\n\n过敏者慎甩;粘膜破损处禁用。\n本品不是药品。"
|
||||
bad := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲乙套盒系列", Node2: "过敏者", Type: "慎用于", Evidence: "过敏者慎甩"}}}
|
||||
if err := validateGraphRelationEvidence(bad, source); err == nil || !strings.Contains(err.Error(), "series heading") {
|
||||
t.Fatal("series title used as local product")
|
||||
}
|
||||
bad.Relation[0].Node1 = "本品"
|
||||
if err := validateGraphRelationEvidence(bad, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
const explicit = "## 甲乙套盒系列\n\n甲乙套盒系列过敏者慎用。"
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲乙套盒系列", Node2: "过敏者", Type: "慎用于", Evidence: "甲乙套盒系列过敏者慎用"}}}
|
||||
if err := validateGraphRelationEvidence(graph, explicit); err != nil {
|
||||
t.Fatal("explicit series rule rejected", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestGraphComparisonPreservesSourceAndOrdinaryNegation(t *testing.T) {
|
||||
for _, tc := range []struct{ source, comparison string }{
|
||||
{"不不建议使用", "不建议使用"},
|
||||
{"不能使用", "不能使用"},
|
||||
{"不不能使用", "不不能使用"},
|
||||
{"不不建议使用", "不不建议使用"},
|
||||
{"开塞露露不不能⻓长期用", "开塞露露不能⻓长期用"},
|
||||
} {
|
||||
if got := graphComparisonText(tc.source); got != tc.comparison {
|
||||
t.Errorf("comparison of %q = %q, want %q", tc.source, got, tc.comparison)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphProductSectionHeadingCannotOwnStorageRule(t *testing.T) {
|
||||
const source = "本品不是药品。〔 储存方法 〗 储存于阴凉干燥处 , 避光 、 隔热 、 密封保存 。"
|
||||
for _, tc := range []struct {
|
||||
name, subject, evidence string
|
||||
wantError bool
|
||||
}{
|
||||
{"section without quoted heading", "储存方法", "储存于阴凉干燥处 , 避光 、 隔热 、 密封保存", true},
|
||||
{"section included in evidence", "储存方法", "〔 储存方法 〗 储存于阴凉干燥处 , 避光 、 隔热 、 密封保存", true},
|
||||
{"local product owns rule", "本品", "储存于阴凉干燥处 , 避光 、 隔热 、 密封保存", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: tc.subject, Node2: "储存于阴凉干燥处,避光、隔热、密封保存", Type: "约束", Evidence: tc.evidence}}}
|
||||
err := validateGraphRelationEvidenceItem(graph, source)
|
||||
if tc.wantError {
|
||||
if err == nil || !strings.Contains(err.Error(), "product section heading") {
|
||||
t.Fatalf("section incorrectly owns product rule: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "储存方法", Node2: "温度与湿度要求", Type: "包含", Evidence: "储存方法包含温度与湿度要求"}}}
|
||||
if err := validateGraphRelationEvidenceItem(graph, "教材的储存方法包含温度与湿度要求。"); err != nil {
|
||||
t.Fatalf("ordinary named concept rejected: %v", err)
|
||||
}
|
||||
for _, evidence := range []string{"注意事项要求记录审核日期", "〔 注意事项 〗 注意事项要求记录审核日期"} {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "注意事项", Node2: "记录审核日期", Type: "约束", Evidence: evidence}}}
|
||||
if err := validateGraphRelationEvidenceItem(graph, "〔 注意事项 〗 注意事项要求记录审核日期。"); err != nil {
|
||||
t.Fatalf("explicit body subject rejected: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphGenericAndCombinedUseEvidence(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, source, from, to, label, quote, wantErr string
|
||||
}{
|
||||
{
|
||||
name: "OCR prohibition preserves original evidence", source: "本品不不能用于破损皮肤。", from: "本品", to: "破损皮肤", label: "禁用于", quote: "本品不不能用于破损皮肤",
|
||||
},
|
||||
{
|
||||
name: "OCR negative advice remains recognized", source: "不不建议使用番泻叶。", from: "番泻叶", to: "使用", label: "不适用于", quote: "不不建议使用番泻叶",
|
||||
},
|
||||
{
|
||||
name: "OCR prohibition cannot become permission", source: "本品不不能用于破损皮肤。", from: "本品", to: "破损皮肤", label: "适用于", quote: "用于破损皮肤", wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "OCR comparison never repairs quotation", source: "本品不不能用于破损皮肤。", from: "本品", to: "破损皮肤", label: "禁用于", quote: "本品不能用于破损皮肤", wantErr: "requires exact evidence",
|
||||
},
|
||||
{
|
||||
name: "avoid combined use", source: "避免与拮抗药物同用。", from: "本品", to: "与拮抗药物同用", label: "禁用于", quote: "避免与拮抗药物同用",
|
||||
},
|
||||
{
|
||||
name: "avoid combined use with alternate conjunction", source: "避免和其他产品合用。", from: "本品", to: "避免和其他产品合用", label: "约束", quote: "避免和其他产品合用",
|
||||
},
|
||||
{
|
||||
name: "avoid simultaneous use", source: "避免与其他产品同时使用。", from: "本品", to: "与其他产品同时使用", label: "不适用于", quote: "避免与其他产品同时使用",
|
||||
},
|
||||
{
|
||||
name: "combined operation cannot disappear", source: "避免与拮抗药物同用。", from: "本品", to: "拮抗药物", label: "禁用于", quote: "避免与拮抗药物同用", wantErr: "omits the combined-use scope",
|
||||
},
|
||||
{
|
||||
name: "avoid combined use cannot become permission", source: "避免和其他产品同时使用。", from: "本品", to: "和其他产品同时使用", label: "适用于", quote: "避免和其他产品同时使用", wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "avoid benefit is not a use prohibition", source: "本品可避免皮肤不适。", from: "本品", to: "皮肤不适", label: "禁用于", quote: "本品可避免皮肤不适", wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "identity cannot borrow effect denial", source: "本品不是药品,不具有治疗、护理、保健作用。", from: "本品", to: "药品", label: "不适用于", quote: "本品不是药品,不具有治疗、护理、保健作用", wantErr: "identity disclaimer",
|
||||
},
|
||||
{
|
||||
name: "identity alone does not restrict use", source: "本品不是药品。", from: "本品", to: "药品", label: "不适用于", quote: "本品不是药品", wantErr: "identity disclaimer",
|
||||
},
|
||||
{
|
||||
name: "effect disclaimer remains supported", source: "本品不是药品,不具有治疗作用。", from: "本品", to: "治疗作用", label: "不支持", quote: "本品不具有治疗作用", wantErr: "requires exact evidence",
|
||||
},
|
||||
{
|
||||
name: "separate effect denial remains valid", source: "本品不是药品,不具有治疗作用。", from: "本品", to: "治疗作用", label: "不支持", quote: "不具有治疗作用",
|
||||
},
|
||||
{
|
||||
name: "state prediction is not a use restriction", source: "第一次会议不会出现投票。", from: "第一次会议", to: "投票", label: "不适用于", quote: "第一次会议不会出现投票", wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "chapter heading is not the actor", source: "## 沟通指南\n\n明确目标支持积极建议。", from: "沟通指南", to: "积极建议", label: "支持", quote: "明确目标支持积极建议", wantErr: "source heading",
|
||||
},
|
||||
{
|
||||
name: "actual named book can support practice", source: "## 沟通指南\n\n沟通指南支持沟通练习。", from: "沟通指南", to: "沟通练习", label: "支持", quote: "沟通指南支持沟通练习",
|
||||
},
|
||||
{
|
||||
name: "uncertainty cannot be omitted", source: "可能是降价导致利润下降。", from: "降价", to: "利润下降", label: "导致", quote: "降价导致利润下降", wantErr: "omits source attribution, time or uncertainty",
|
||||
},
|
||||
{
|
||||
name: "uncertainty preserved in scoped association", source: "降价可能导致利润下降。", from: "降价", to: "利润下降", label: "关联", quote: "降价可能导致利润下降",
|
||||
},
|
||||
{
|
||||
name: "uncertainty in evidence alone cannot make causal edge certain", source: "降价可能导致利润下降。", from: "降价", to: "利润下降", label: "导致", quote: "降价可能导致利润下降", wantErr: "turns source possibility",
|
||||
},
|
||||
{
|
||||
name: "uncertainty scoped in causal target", source: "降价可能导致利润下降。", from: "降价", to: "可能的利润下降", label: "导致", quote: "降价可能导致利润下降",
|
||||
},
|
||||
{
|
||||
name: "explicit certainty does not need uncertainty scope", source: "降价导致利润下降。", from: "降价", to: "利润下降", label: "导致", quote: "降价导致利润下降",
|
||||
},
|
||||
{
|
||||
name: "historical view cannot become general truth", source: "以前我认为,工作支持自我肯定。", from: "工作", to: "自我肯定", label: "支持", quote: "以前我认为,工作支持自我肯定", wantErr: "attributed opinion",
|
||||
},
|
||||
{
|
||||
name: "historical view remains attributed", source: "以前我认为,工作支持自我肯定。", from: "叙述者以前的观点", to: "工作支持自我肯定", label: "关联", quote: "以前我认为,工作支持自我肯定",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw, err := json.Marshal([]map[string]string{{"entity1": tc.from, "entity2": tc.to, "relation": tc.label, "evidence": tc.quote}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: string(raw), FinishReason: "stop"}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), tc.source)
|
||||
if tc.wantErr != "" {
|
||||
if graph != nil || err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("graph=%+v, err=%v, want %q", graph, err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || graph == nil || len(graph.Relation) != 1 || graph.Relation[0].Evidence != tc.quote {
|
||||
t.Fatalf("supported source quotation was rejected or changed: %+v, %v", graph, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphEmptyCoverageRequiresReview(t *testing.T) {
|
||||
for _, response := range []string{"[]", `[{"entity":"标题","entity_attributes":["没有关系"]}]`} {
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: response, FinishReason: "stop"}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), "标题,没有关系。")
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), "graph_needs_review") || model.calls != 2 {
|
||||
t.Fatalf("zero-edge coverage must require review after one correction: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphCoverageCorrectionCanRestoreSupportedRelationship(t *testing.T) {
|
||||
const source = "基础概念是实践练习的先修条件。"
|
||||
model := &extractionResponseChat{responses: []*types.ChatResponse{
|
||||
{Content: `[{"entity":"基础概念"},{"entity":"实践练习"}]`, FinishReason: "stop"},
|
||||
{Content: `[{"entity1":"基础概念","entity2":"实践练习","relation":"先修条件","evidence":"基础概念是实践练习的先修条件。"}]`, FinishReason: "stop"},
|
||||
}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 1 || model.calls != 2 {
|
||||
t.Fatalf("supported correction failed: %+v, %v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
for _, instruction := range []string{"actual subject", "Product-use rules", "speaker, time and uncertainty", "不是药品", "Empty or entity-only results require coverage review"} {
|
||||
if !strings.Contains(model.messages[0].Content, instruction) {
|
||||
t.Errorf("graph prompt lacks %q", instruction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphProductBoundaryAndConstraintRegressions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, source, from, to, label, quote, wantErr string
|
||||
}{
|
||||
{name: "OCR spaced numbered product", source: "本品不应用于疾病预防。\n\n02 丨 NMN 寻幽紧爱修护套 ( 紧致凝胶 )\n产品成分:甲。", from: "NMN寻幽紧爱修护套(紧致凝胶)", to: "疾病预防", label: "不适用于", quote: "本品不应用于疾病预防", wantErr: "earlier unnamed product"},
|
||||
{name: "brand and name in separate table columns", source: "不得用于疾病预防。\n\n## 工作表1\n\n| 品牌名称 | 产品名称 | 功效 |\n| --- | --- | --- |\n| 玉丹 | 精华油 | 保湿 |", from: "玉丹精华油", to: "疾病预防", label: "不适用于", quote: "不得用于疾病预防", wantErr: "earlier unnamed product"},
|
||||
{name: "same table row attribution retained", source: "不得用于疾病预防。\n\n| 品牌名称 | 产品名称 | 功效 |\n| --- | --- | --- |\n| 玉丹 | 精华油 | 保湿 |", from: "玉丹精华油", to: "保湿", label: "关联", quote: "| 玉丹 | 精华油 | 保湿 |"},
|
||||
{name: "constraint retains prohibition", source: "避免与拮抗药物同用。", from: "本品", to: "拮抗药物同用", label: "约束", quote: "避免与拮抗药物同用", wantErr: "omits the negation"},
|
||||
{name: "constraint preserves prohibition", source: "避免与拮抗药物同用。", from: "本品", to: "避免与拮抗药物同用", label: "约束", quote: "避免与拮抗药物同用"},
|
||||
{name: "stop action cannot disappear", source: "使用过程中,如有不适,请立即停止使用。", from: "使用过程中如有不适", to: "使用", label: "约束", quote: "使用过程中,如有不适,请立即停止使用", wantErr: "omits the stop-use action"},
|
||||
{name: "stop condition cannot disappear", source: "如有不适,请立即停止使用。", from: "本品", to: "立即停止使用", label: "约束", quote: "如有不适,请立即停止使用", wantErr: "omits the stop-use condition"},
|
||||
{name: "storage is not prohibition", source: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒。", from: "本品", to: "阳光直晒", label: "禁用于", quote: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒", wantErr: "misclassifies a storage constraint"},
|
||||
{name: "storage negation cannot disappear", source: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒。", from: "本品", to: "阳光直晒", label: "约束", quote: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒", wantErr: "omits the storage action"},
|
||||
{name: "storage action retained", source: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒。", from: "本品", to: "阴凉干燥处存放并避免阳光直晒", label: "约束", quote: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒"},
|
||||
{name: "storage metadata is not the requirement", source: "贮存方法:避光密封保存。", from: "本品", to: "贮存条件", label: "约束", quote: "贮存方法:避光密封保存", wantErr: "omits the storage action"},
|
||||
{name: "child access restriction cannot disappear", source: "请放置于儿童不易触及之处。", from: "本品", to: "儿童触及", label: "约束", quote: "请放置于儿童不易触及之处", wantErr: "storage action"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: tc.from, Node2: tc.to, Type: tc.label, Evidence: tc.quote}}}
|
||||
err := validateGraphRelationEvidence(graph, tc.source)
|
||||
if tc.wantErr == "" && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) {
|
||||
t.Fatalf("got %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphProductPronounsCannotMergeAcrossSections(t *testing.T) {
|
||||
const source = "请将本品置于阴凉干燥处存放。\n\n〔 产品名称 〕白番茄饮品\n〔 贮存方法 〕请置于阴凉处保存。"
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{
|
||||
{Node1: "本品", Node2: "阴凉干燥处存放", Type: "约束", Evidence: "请将本品置于阴凉干燥处存放"},
|
||||
{Node1: "本品", Node2: "阴凉处保存", Type: "约束", Evidence: "请置于阴凉处保存"},
|
||||
}}
|
||||
if err := validateGraphRelationEvidence(graph, source); err == nil || !strings.Contains(err.Error(), "merges an unnamed product") {
|
||||
t.Fatalf("ambiguous merged referent accepted: %v", err)
|
||||
}
|
||||
graph.Relation[1].Node1 = "白番茄饮品"
|
||||
if err := validateGraphRelationEvidence(graph, source); err != nil {
|
||||
t.Fatalf("distinct products should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphCorrectionReceivesMultipleFailures(t *testing.T) {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{
|
||||
{Node1: "本品", Node2: "其他产品合用", Type: "约束", Evidence: "避免与其他产品合用"},
|
||||
{Node1: "本品", Node2: "使用", Type: "约束", Evidence: "如有不适立即停止使用"},
|
||||
}}
|
||||
err := validateGraphRelationEvidence(graph, "避免与其他产品合用。如有不适立即停止使用。")
|
||||
if err == nil || !strings.Contains(err.Error(), "omits the negation") || !strings.Contains(err.Error(), "omits the stop-use action") {
|
||||
t.Fatalf("correction must expose both failures: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphExplicitRelationDirections(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, from, to, label, quote string
|
||||
reversed bool
|
||||
}{
|
||||
{"part cannot contain whole", "交流", "会议", "包含", "交流是会议的重要内容", true},
|
||||
{"whole contains part", "会议", "交流", "包含", "交流是会议的重要内容", false},
|
||||
{"literal whole and part reversed", "交流", "会议", "包含", "会议包含交流", true},
|
||||
{"literal whole and part retained", "会议", "交流", "包含", "会议包含交流", false},
|
||||
{"goal cannot precede prerequisite", "实践练习", "基础概念", "先修条件", "基础概念是实践练习的先修条件", true},
|
||||
{"prerequisite precedes goal", "基础概念", "实践练习", "先修条件", "基础概念是实践练习的先修条件", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: tc.from, Node2: tc.to, Type: tc.label, Evidence: tc.quote}}}
|
||||
err := validateGraphRelationEvidence(graph, tc.quote)
|
||||
if tc.reversed && (err == nil || !strings.Contains(err.Error(), "reverses the explicitly stated relation direction")) {
|
||||
t.Fatalf("reversed direction passed: %v", err)
|
||||
}
|
||||
if !tc.reversed && err != nil {
|
||||
t.Fatalf("valid direction rejected: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphUsageProcedureCannotTreatProductAsPrerequisiteGoal(t *testing.T) {
|
||||
const source = "使用本品前先清洗双手,然后取本品涂于手部。"
|
||||
for _, target := range []string{"本品", "本产品", "该产品", "本品(未命名)"} {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{
|
||||
Node1: "先清洗双手,然后取本品涂于手部", Node2: target,
|
||||
Type: "先修条件", Evidence: source,
|
||||
}}}
|
||||
if err := validateGraphRelationEvidence(graph, source); err == nil || !strings.Contains(err.Error(), "product itself as a prerequisite goal") {
|
||||
t.Fatalf("invalid product prerequisite accepted for %q: %v", target, err)
|
||||
}
|
||||
}
|
||||
for _, relation := range []*types.GraphRelation{
|
||||
{Node1: "清洗双手", Node2: "取本品涂于手部", Type: "先修条件", Evidence: source},
|
||||
{Node1: "本品", Node2: "先清洗双手,然后取本品涂于手部", Type: "约束", Evidence: source},
|
||||
} {
|
||||
if err := validateGraphRelationEvidence(&types.GraphData{Relation: []*types.GraphRelation{relation}}, source); err != nil {
|
||||
t.Fatalf("supported action relation rejected: %+v: %v", relation, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,818 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
type extractionResponseChat struct {
|
||||
response *types.ChatResponse
|
||||
responses []*types.ChatResponse
|
||||
calls int
|
||||
options *chat.ChatOptions
|
||||
messages []chat.Message
|
||||
}
|
||||
|
||||
func (m *extractionResponseChat) Chat(_ context.Context, messages []chat.Message, options *chat.ChatOptions) (*types.ChatResponse, error) {
|
||||
m.options = options
|
||||
m.messages = messages
|
||||
m.calls++
|
||||
if len(m.responses) >= m.calls {
|
||||
return m.responses[m.calls-1], nil
|
||||
}
|
||||
return m.response, nil
|
||||
}
|
||||
|
||||
func (*extractionResponseChat) ChatStream(context.Context, []chat.Message, *chat.ChatOptions) (<-chan types.StreamResponse, error) {
|
||||
panic("unexpected streaming extraction")
|
||||
}
|
||||
|
||||
func (*extractionResponseChat) GetModelName() string { return "extraction-test" }
|
||||
func (*extractionResponseChat) GetModelID() string { return "extraction-test" }
|
||||
|
||||
func TestExtractorRequiresCompletedGraphContent(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
response *types.ChatResponse
|
||||
wantErr string
|
||||
}{
|
||||
{name: "missing response", wantErr: "no response"},
|
||||
{
|
||||
name: "reasoning exhausted the token budget",
|
||||
response: &types.ChatResponse{
|
||||
FinishReason: "length", ReasoningContent: "private reasoning",
|
||||
Usage: types.TokenUsage{CompletionTokens: 4096},
|
||||
},
|
||||
wantErr: "exceeded the output token limit",
|
||||
},
|
||||
{
|
||||
name: "valid JSON cannot hide token exhaustion",
|
||||
response: &types.ChatResponse{
|
||||
Content: `[{"entity":"Alice"}]`, FinishReason: "length",
|
||||
},
|
||||
wantErr: "exceeded the output token limit",
|
||||
},
|
||||
{
|
||||
name: "reasoning is not a graph response",
|
||||
response: &types.ChatResponse{
|
||||
Content: " \n\t", ReasoningContent: `[{"entity":"private reasoning"}]`, FinishReason: "stop",
|
||||
},
|
||||
wantErr: "empty content",
|
||||
},
|
||||
{
|
||||
name: "explicit empty result succeeds",
|
||||
response: &types.ChatResponse{Content: `[]`, FinishReason: "stop"},
|
||||
},
|
||||
{
|
||||
name: "unknown object is not an empty result",
|
||||
response: &types.ChatResponse{Content: `{"unexpected":true}`, FinishReason: "stop"},
|
||||
wantErr: "no recognized graph extractions",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
model := &extractionResponseChat{response: tc.response}
|
||||
extractor := NewExtractor(model, &types.PromptTemplateStructured{Description: "Extract entities"})
|
||||
graph, err := extractor.Extract(context.Background(), "Alice knows Bob")
|
||||
if model.options == nil || model.options.Thinking == nil || *model.options.Thinking {
|
||||
t.Fatal("entity extraction must explicitly disable thinking")
|
||||
}
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
if strings.Contains(err.Error(), "private reasoning") {
|
||||
t.Fatal("error must not expose reasoning content")
|
||||
}
|
||||
if graph != nil {
|
||||
t.Fatalf("failed extraction returned graph: %+v", graph)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || graph == nil || len(graph.Node) != 0 || len(graph.Relation) != 0 {
|
||||
t.Fatalf("explicit empty result = %+v, %v", graph, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphExtractorRequiresSourceEvidence(t *testing.T) {
|
||||
// These contraindications are from a production product-information chunk,
|
||||
// including its OCR typo; the failed extraction claimed positive suitability.
|
||||
const productWarnings = "私蜜妍+蜜桃初醒套盒系列\n3 . 过敏者慎甩 4 . 粘膜破损处禁用 ;\n5 . 不得用于性生活中对性病的预防 。\n〖 警示说明 〗 本品不是药品 , 不具有治疗 、 护理 、 保健作用 。"
|
||||
for _, tc := range []struct {
|
||||
name, content, from, to, relation, evidence, wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing evidence", content: "本品适用于成人", from: "本品", to: "成人", relation: "适用于",
|
||||
wantErr: "requires exact evidence",
|
||||
},
|
||||
{
|
||||
name: "invented evidence", content: "本品适用于成人", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于儿童",
|
||||
wantErr: "requires exact evidence",
|
||||
},
|
||||
{
|
||||
name: "whitespace is part of source", content: "本品 适用于成人", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于成人",
|
||||
wantErr: "requires exact evidence",
|
||||
},
|
||||
{
|
||||
name: "exact positive evidence", content: "本品适用于成人", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于成人",
|
||||
},
|
||||
{
|
||||
name: "real allergy warning", content: productWarnings, from: "私蜜妍+蜜桃初醒套盒系列", to: "过敏者", relation: "适用于", evidence: "过敏者慎甩",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "real allergy warning reversed", content: productWarnings, from: "过敏者", to: "本品", relation: "适用于", evidence: "过敏者慎甩",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "real damaged mucosa warning", content: productWarnings, from: "私蜜妍+蜜桃初醒套盒系列", to: "粘膜破损处", relation: "适用于", evidence: "粘膜破损处禁用",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "real STI prevention warning", content: productWarnings, from: "私蜜妍+蜜桃初醒套盒系列", to: "性生活中对性病的预防", relation: "适用于", evidence: "不得用于性生活中对性病的预防",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "quote cannot hide preceding negation", content: productWarnings, from: "私蜜妍+蜜桃初醒套盒系列", to: "性生活中对性病的预防", relation: "用于", evidence: "用于性生活中对性病的预防",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "target alias cannot hide preceding negation", content: "本品不得用于性病预防。", from: "本品", to: "预防性病", relation: "用于", evidence: "用于性病预防",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "real disclaimer list", content: productWarnings, from: "本品", to: "护理", relation: "具有", evidence: "不具有治疗 、 护理 、 保健作用",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "comma separated negative list", content: "本品不具有治疗,护理,保健作用。", from: "本品", to: "护理", relation: "具有", evidence: "护理",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "later comma separated negative list item", content: "本品不具有治疗,护理,保健作用。", from: "本品", to: "保健作用", relation: "具有", evidence: "保健作用",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "multiple negative clauses", content: "本品不得用于性病预防,也不具有治疗作用。", from: "本品", to: "治疗作用", relation: "具有", evidence: "治疗作用",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "accurate caution preserved", content: productWarnings, from: "本品", to: "过敏者", relation: "慎用于", evidence: "过敏者慎甩",
|
||||
},
|
||||
{
|
||||
name: "accurate prohibition preserved", content: productWarnings, from: "本品", to: "粘膜破损处", relation: "禁用于", evidence: "粘膜破损处禁用",
|
||||
},
|
||||
{
|
||||
name: "accurate non applicability preserved", content: productWarnings, from: "本品", to: "性生活中对性病的预防", relation: "不适用于", evidence: "不得用于性生活中对性病的预防",
|
||||
},
|
||||
{
|
||||
name: "avoid contact prohibition preserved", content: "〔 注意事项 〗 避免接触眼睛 , 若产品不慎入眼 , 请立即用清水冲洗 。", from: "本品", to: "眼睛", relation: "禁用于", evidence: "避免接触眼睛",
|
||||
},
|
||||
{
|
||||
name: "avoid contact restriction with exposure alias", content: "〔 注意事项 〗 避免接触眼睛 , 若产品不慎入眼 , 请立即用清水冲洗 , 如有不适 , 请立即停止使用。", from: "本品", to: "入眼", relation: "禁用于", evidence: "避免接触眼睛 , 若产品不慎入眼 , 请立即用清水冲洗 , 如有不适 , 请立即停止使用",
|
||||
},
|
||||
{
|
||||
name: "avoid contact is not positive use", content: "本品避免接触眼睛。", from: "本品", to: "眼睛", relation: "适用于", evidence: "本品避免接触眼睛",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "avoid contact is not conditional permission", content: "本品避免接触眼睛。", from: "本品", to: "眼睛", relation: "条件适用于", evidence: "本品避免接触眼睛",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "avoid contact is not caution", content: "本品避免接触眼睛。", from: "本品", to: "眼睛", relation: "慎用于", evidence: "本品避免接触眼睛",
|
||||
wantErr: "weakens a source prohibition",
|
||||
},
|
||||
{
|
||||
name: "benefit avoiding discomfort is not prohibition", content: "本品帮助避免干燥引起的不适。", from: "本品", to: "干燥引起的不适", relation: "禁用于", evidence: "本品帮助避免干燥引起的不适",
|
||||
wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "damaged OCR restriction remains unresolved", content: "避女性三期僮生 ( 羟期 . 孕期飞产期 } 。", from: "本品", to: "女性三期(羟期.孕期飞产期)", relation: "禁用于", evidence: "避女性三期僮生 ( 羟期 . 孕期飞产期 }",
|
||||
wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "accidental exposure response is not suitability", content: "若不慎入眼 , 请立即用清水冲洗 , 严重者立即就医。", from: "本品", to: "眼部接触", relation: "条件适用于", evidence: "若不慎入眼 , 请立即用清水冲洗 , 严重者立即就医",
|
||||
wantErr: "without an explicit source use claim",
|
||||
},
|
||||
{
|
||||
name: "accidental exposure response remains a constraint", content: "若不慎入眼 , 请立即用清水冲洗 , 严重者立即就医。", from: "本品", to: "不慎入眼后立即用清水冲洗", relation: "约束", evidence: "若不慎入眼 , 请立即用清水冲洗 , 严重者立即就医",
|
||||
},
|
||||
{
|
||||
name: "unrelated warning in same sentence", content: "本品适用于成人,过敏者禁用。", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于成人,过敏者禁用",
|
||||
},
|
||||
{
|
||||
name: "unrelated warning before positive claim", content: "过敏者禁用,但本品适用于成人。", from: "本品", to: "成人", relation: "适用于", evidence: "过敏者禁用,但本品适用于成人",
|
||||
},
|
||||
{
|
||||
name: "unrelated condition before positive claim", content: "如果不适请停用,本品适用于成人。", from: "本品", to: "成人", relation: "适用于", evidence: "如果不适请停用,本品适用于成人",
|
||||
},
|
||||
{
|
||||
name: "unrelated disclaimer in previous sentence", content: "本品不是药品,不具有治疗作用。本品适用于成人日常清洁。", from: "本品", to: "成人日常清洁", relation: "适用于", evidence: "本品适用于成人日常清洁",
|
||||
},
|
||||
{
|
||||
name: "OCR sections without punctuation", content: "〖 使用范围 〗 用于阴道黏膜抑菌 〔 注意事项 〗 1. 不得用于治疗疾病或者改善症状 ;", from: "本品", to: "阴道黏膜", relation: "适用于", evidence: "用于阴道黏膜抑菌",
|
||||
},
|
||||
{
|
||||
name: "restriction inside OCR section remains enforced", content: "〖 使用范围 〗 用于阴道黏膜抑菌 〔 注意事项 〗 1. 不得用于治疗疾病或者改善症状 ;", from: "本品", to: "治疗疾病", relation: "适用于", evidence: "用于治疗疾病或者改善症状",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "should not be used restriction", content: "本品不应用于性生活中对性病的预防;", from: "本品", to: "性生活中对性病的预防", relation: "适用于", evidence: "用于性生活中对性病的预防",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "unrelated negative adjective", content: "本品用于改善不良习惯。", from: "本品", to: "不良习惯", relation: "改善", evidence: "本品用于改善不良习惯",
|
||||
},
|
||||
{
|
||||
name: "conditional suitability cannot become unconditional", content: "仅在医师指导下,本品适用于成人。", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于成人",
|
||||
wantErr: "conditional source claim",
|
||||
},
|
||||
{
|
||||
name: "inline conditional suitability", content: "本品仅限在医师指导下适用于成人。", from: "本品", to: "成人", relation: "适用于", evidence: "本品仅限在医师指导下适用于成人",
|
||||
wantErr: "conditional source claim",
|
||||
},
|
||||
{
|
||||
name: "conditional label preserved", content: "仅在医师指导下,本品适用于成人。", from: "本品", to: "成人", relation: "条件适用于", evidence: "仅在医师指导下,本品适用于成人",
|
||||
},
|
||||
{
|
||||
name: "conditional use requires a real condition", content: "本品适用于成人。", from: "本品", to: "成人", relation: "条件适用于", evidence: "本品适用于成人",
|
||||
wantErr: "without an explicit condition",
|
||||
},
|
||||
{
|
||||
name: "conditional use evidence retains the condition", content: "仅在医师指导下,本品适用于成人。", from: "本品", to: "成人", relation: "条件适用于", evidence: "本品适用于成人",
|
||||
wantErr: "without an explicit condition",
|
||||
},
|
||||
{
|
||||
name: "conditional use cannot substitute a condition for its use target", content: "初次使用本品者先在手臂内侧试用 , 无过敏现象再使用。", from: "本品", to: "过敏现象", relation: "条件适用于", evidence: "初次使用本品者先在手臂内侧试用 , 无过敏现象再使用",
|
||||
wantErr: "without a source-grounded use target",
|
||||
},
|
||||
{
|
||||
name: "conditional procedure retains source use target", content: "清洗外阴后 , 将凝胶推入阴道。", from: "凝胶", to: "阴道", relation: "条件适用于", evidence: "清洗外阴后 , 将凝胶推入阴道",
|
||||
},
|
||||
{
|
||||
name: "ordinary causal condition is not product suitability", content: "如果持续降价,降价导致利润下降。", from: "降价", to: "利润下降", relation: "导致", evidence: "如果持续降价,降价导致利润下降",
|
||||
},
|
||||
{
|
||||
name: "ordinary support condition is not product suitability", content: "只有认真倾听,倾听支持成交。", from: "倾听", to: "成交", relation: "支持", evidence: "只有认真倾听,倾听支持成交",
|
||||
},
|
||||
{
|
||||
name: "ordinary conditional evidence cannot omit condition", content: "只有认真倾听,倾听支持成交。", from: "倾听", to: "成交", relation: "支持", evidence: "倾听支持成交",
|
||||
wantErr: "omits a source condition",
|
||||
},
|
||||
{
|
||||
name: "unrelated warning before general support", content: "不得强行推销,倾听支持成交。", from: "倾听", to: "成交", relation: "支持", evidence: "倾听支持成交",
|
||||
},
|
||||
{
|
||||
name: "explicit negative causation", content: "降价不导致客户忠诚。", from: "降价", to: "客户忠诚", relation: "不导致", evidence: "降价不导致客户忠诚",
|
||||
},
|
||||
{
|
||||
name: "explicit future negative causation", content: "降价不会导致客户忠诚。", from: "降价", to: "客户忠诚", relation: "不导致", evidence: "降价不会导致客户忠诚",
|
||||
},
|
||||
{
|
||||
name: "explicit negative support", content: "强行推销不支持客户信任。", from: "强行推销", to: "客户信任", relation: "不支持", evidence: "强行推销不支持客户信任",
|
||||
},
|
||||
{
|
||||
name: "negative causation cannot be positive", content: "降价不导致客户忠诚。", from: "降价", to: "客户忠诚", relation: "导致", evidence: "降价不导致客户忠诚",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "negative support cannot be positive", content: "强行推销不支持客户信任。", from: "强行推销", to: "客户信任", relation: "支持", evidence: "强行推销不支持客户信任",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "use prohibition cannot imply negative causation", content: "本品不得用于疾病治疗。", from: "本品", to: "疾病治疗", relation: "不导致", evidence: "本品不得用于疾病治疗",
|
||||
wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "combined use cannot become blanket ingredient prohibition", content: "本品不得与阴离子成分同时使用。", from: "本品", to: "阴离子成分", relation: "禁用于", evidence: "本品不得与阴离子成分同时使用",
|
||||
wantErr: "omits the combined-use scope",
|
||||
},
|
||||
{
|
||||
name: "combined use prohibition preserves operation", content: "本品不得与阴离子成分同时使用。", from: "本品", to: "与阴离子成分同时使用", relation: "禁用于", evidence: "本品不得与阴离子成分同时使用",
|
||||
},
|
||||
{
|
||||
name: "combined use constraint preserves operation", content: "本品不可与其他产品一起使用。", from: "本品", to: "不可与其他产品合用", relation: "约束", evidence: "本品不可与其他产品一起使用",
|
||||
},
|
||||
{
|
||||
name: "substitution cannot become blanket drug prohibition", content: "本品不是药品 , 不得代替药品使用。", from: "本品", to: "药品", relation: "不适用于", evidence: "本品不是药品 , 不得代替药品使用",
|
||||
wantErr: "omits the substitution scope",
|
||||
},
|
||||
{
|
||||
name: "substitution prohibition preserves operation", content: "本品不是药品 , 不得代替药品使用。", from: "本品", to: "代替药品使用", relation: "不适用于", evidence: "本品不是药品 , 不得代替药品使用",
|
||||
},
|
||||
{
|
||||
name: "substitution constraint preserves operation", content: "本品不得替代药品。", from: "本品", to: "代替药品", relation: "约束", evidence: "本品不得替代药品",
|
||||
},
|
||||
{
|
||||
name: "constraint preserved", content: "本品只限于完整皮肤。", from: "本品", to: "完整皮肤", relation: "约束", evidence: "本品只限于完整皮肤",
|
||||
},
|
||||
{
|
||||
name: "avoidance is not conditional permission", content: "用于阴道时 , 注 0 避开女性崆期 、 产期 。", from: "本品", to: "产期", relation: "条件适用于", evidence: "用于阴道时 , 注 0 避开女性崆期 、 产期",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "do not use is not caution", content: "注意三期勿用。", from: "本品", to: "三期", relation: "慎用于", evidence: "注意三期勿用",
|
||||
wantErr: "weakens a source prohibition",
|
||||
},
|
||||
{
|
||||
name: "stop use cannot become caution", content: "使用过程中 , 如有不适 , 请立即停止使用 。", from: "本品", to: "如有不适", relation: "慎用于", evidence: "使用过程中 , 如有不适 , 请立即停止使用",
|
||||
wantErr: "weakens a source prohibition",
|
||||
},
|
||||
{
|
||||
name: "truncated quote cannot hide stop use", content: "使用过程中 , 如有不适 , 请立即停止使用 。", from: "本品", to: "不适", relation: "慎用于", evidence: "如有不适",
|
||||
wantErr: "weakens a source prohibition",
|
||||
},
|
||||
{
|
||||
name: "stop use constraint preserved", content: "使用过程中 , 如有不适 , 请立即停止使用 。", from: "本品", to: "不适时立即停止使用", relation: "约束", evidence: "使用过程中 , 如有不适 , 请立即停止使用",
|
||||
},
|
||||
{
|
||||
name: "suppression is not support of first microbe", content: "本品对白色念珠菌 , 金黄色葡萄球菌 , 大肠杆菌有抑制作用。", from: "本品", to: "白色念珠菌", relation: "支持", evidence: "本品对白色念珠菌 , 金黄色葡萄球菌 , 大肠杆菌有抑制作用",
|
||||
wantErr: "reverses source suppression",
|
||||
},
|
||||
{
|
||||
name: "suppression is not support of last microbe", content: "本品对白色念珠菌 , 金黄色葡萄球菌 , 大肠杆菌有抑制作用。", from: "本品", to: "大肠杆菌", relation: "支持", evidence: "本品对白色念珠菌 , 金黄色葡萄球菌 , 大肠杆菌有抑制作用",
|
||||
wantErr: "reverses source suppression",
|
||||
},
|
||||
{
|
||||
name: "literal suppression relation preserved", content: "本品对白色念珠菌有抑制作用。", from: "本品", to: "白色念珠菌", relation: "抑制", evidence: "本品对白色念珠菌有抑制作用",
|
||||
},
|
||||
{
|
||||
name: "suppression benefit is not reversed", content: "本品支持阴道抑菌。", from: "本品", to: "阴道抑菌", relation: "支持", evidence: "本品支持阴道抑菌",
|
||||
},
|
||||
{
|
||||
name: "benefit is not non applicability", content: "精华凝露激发内在欲望,告别冷淡。", from: "精华凝露", to: "冷淡", relation: "不适用于", evidence: "精华凝露激发内在欲望,告别冷淡",
|
||||
wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "benefit is not denied support", content: "精华凝露激发内在欲望,告别冷淡。", from: "精华凝露", to: "冷淡", relation: "不支持", evidence: "精华凝露激发内在欲望,告别冷淡",
|
||||
wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "explicit denial preserved", content: "本品不具有治疗 、 护理 、 保健作用。", from: "本品", to: "治疗、护理、保健作用", relation: "不支持", evidence: "本品不具有治疗 、 护理 、 保健作用",
|
||||
},
|
||||
{
|
||||
name: "body site cannot be removed from prohibition", content: "哺乳期请勿在乳头使用。", from: "本品", to: "哺乳期", relation: "禁用于", evidence: "哺乳期请勿在乳头使用",
|
||||
wantErr: "omits the body-site scope",
|
||||
},
|
||||
{
|
||||
name: "scoped site prohibition preserved", content: "哺乳期请勿在乳头使用。", from: "本品", to: "哺乳期乳头使用", relation: "禁用于", evidence: "哺乳期请勿在乳头使用",
|
||||
},
|
||||
{
|
||||
name: "unrelated site prohibition not borrowed", content: "注意三期勿用 , 哺乳期请勿在乳头使用。", from: "本品", to: "三期", relation: "禁用于", evidence: "注意三期勿用 , 哺乳期请勿在乳头使用",
|
||||
},
|
||||
{
|
||||
name: "use scope cannot be removed from prohibition", content: "用于阴道时 , 注 0 避开女性崆期 、 产期。", from: "本品", to: "产期", relation: "禁用于", evidence: "用于阴道时 , 注 0 避开女性崆期 、 产期",
|
||||
wantErr: "omits the use scope",
|
||||
},
|
||||
{
|
||||
name: "scoped use prohibition preserved", content: "用于阴道时 , 注 0 避开女性崆期 、 产期。", from: "本品", to: "产期阴道使用", relation: "禁用于", evidence: "用于阴道时 , 注 0 避开女性崆期 、 产期",
|
||||
},
|
||||
{
|
||||
name: "invalid self relation is not silently discarded", content: "本品适用于成人", from: "本品", to: "本品", relation: "适用于", evidence: "本品适用于成人",
|
||||
wantErr: "is invalid",
|
||||
},
|
||||
{
|
||||
name: "quantity endpoint must match evidence", content: "紧紧丹富含3350ppm多肽。", from: "紧紧丹", to: "6650ppm多肽", relation: "包含", evidence: "富含3350ppm多肽",
|
||||
wantErr: "endpoint quantity absent",
|
||||
},
|
||||
{
|
||||
name: "quantity cannot come from another product", content: "紧紧丹富含3350ppm多肽。另一产品含6650ppm多肽。", from: "紧紧丹", to: "6650ppm多肽", relation: "包含", evidence: "富含3350ppm多肽",
|
||||
wantErr: "endpoint quantity absent",
|
||||
},
|
||||
{
|
||||
name: "quantity cannot match numeric substring", content: "紧紧丹富含3350ppm多肽。", from: "紧紧丹", to: "350ppm多肽", relation: "包含", evidence: "富含3350ppm多肽",
|
||||
wantErr: "endpoint quantity absent",
|
||||
},
|
||||
{
|
||||
name: "exact quantity preserved", content: "紧紧丹富含3350ppm多肽。", from: "紧紧丹", to: "3350ppm多肽", relation: "包含", evidence: "富含3350ppm多肽",
|
||||
},
|
||||
{
|
||||
name: "OCR decimal typography preserves amount", content: "本品含乳酸 2 . 0 % 。", from: "本品", to: "乳酸2.0%", relation: "包含", evidence: "本品含乳酸 2 . 0 %",
|
||||
},
|
||||
{
|
||||
name: "antibacterial activity is not suppressed", content: "私蜜叫溶菌酶 《 丰动抑菌连蛋白丰动修复", from: "私蜜叫溶菌酶", to: "抑菌", relation: "抑制", evidence: "私蜜叫溶菌酶 《 丰动抑菌连蛋白丰动修复",
|
||||
wantErr: "no explicit suppression predicate",
|
||||
},
|
||||
{
|
||||
name: "suppression predicate must be explicit", content: "本品对白色念珠菌有效。", from: "本品", to: "白色念珠菌", relation: "抑制", evidence: "本品对白色念珠菌有效",
|
||||
wantErr: "no explicit suppression predicate",
|
||||
},
|
||||
{
|
||||
name: "explicit suppression of activity is preserved", content: "此成分抑制抑菌反应。", from: "此成分", to: "抑菌反应", relation: "抑制", evidence: "此成分抑制抑菌反应",
|
||||
},
|
||||
{
|
||||
name: "antimicrobial predicate preserved", content: "本品对金黄色葡萄球菌有抗菌作用。", from: "本品", to: "金黄色葡萄球菌", relation: "抑制", evidence: "本品对金黄色葡萄球菌有抗菌作用",
|
||||
},
|
||||
{
|
||||
name: "storage is not conditional suitability", content: "〔 贮存条件 〗 请将本品置于阴凉干燥处存放。", from: "本品", to: "阴凉干燥处存放", relation: "条件适用于", evidence: "贮存条件 〗 请将本品置于阴凉干燥处存放",
|
||||
wantErr: "misclassifies a storage constraint",
|
||||
},
|
||||
{
|
||||
name: "storage condition retained", content: "〔 贮存条件 〗 请将本品置于阴凉干燥处存放。", from: "本品", to: "阴凉干燥处存放", relation: "约束", evidence: "贮存条件 〗 请将本品置于阴凉干燥处存放",
|
||||
},
|
||||
{
|
||||
name: "use cannot be inferred from benefit", content: "精华凝露提高女性阴蒂、阴道、G点部位的敏感度。", from: "精华凝露", to: "女性阴蒂、阴道、G点部位", relation: "适用于", evidence: "提高女性阴蒂、阴道、G点部位的敏感度",
|
||||
wantErr: "without an explicit source use claim",
|
||||
},
|
||||
{
|
||||
name: "use cannot be inferred from OCR fragment", content: "本品荫堆崆丰部。", from: "本品", to: "阴道", relation: "条件适用于", evidence: "本品荫堆崆丰部",
|
||||
wantErr: "without an explicit source use claim",
|
||||
},
|
||||
{
|
||||
name: "preceding fragment cannot belong to later product", content: "请将本品置于阴凉干燥处存放。\n\n〔 产品名称 〗 白番茄胶原蛋白肽饮品。", from: "白番茄胶原蛋白肽饮品", to: "阴凉干燥处存放", relation: "约束", evidence: "请将本品置于阴凉干燥处存放",
|
||||
wantErr: "earlier unnamed product fragment",
|
||||
},
|
||||
{
|
||||
name: "unresolved preceding product preserved", content: "请将本品置于阴凉干燥处存放。\n\n〔 产品名称 〗 白番茄胶原蛋白肽饮品。", from: "本品", to: "阴凉干燥处存放", relation: "约束", evidence: "请将本品置于阴凉干燥处存放",
|
||||
},
|
||||
{
|
||||
name: "same warning in named product also supported", content: "如有不适请停止使用。\n\n〔 产品名称 〗 柔肤霜。如有不适请停止使用。", from: "柔肤霜", to: "不适时停止使用", relation: "约束", evidence: "如有不适请停止使用",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
encoded, err := json.Marshal([]map[string]string{{
|
||||
"entity1": tc.from, "entity2": tc.to, "relation": tc.relation, "evidence": tc.evidence,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: string(encoded), FinishReason: "stop"}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), tc.content)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) || graph != nil {
|
||||
t.Fatalf("graph = %+v, error = %v; want nil graph and %q", graph, err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || graph == nil || len(graph.Relation) != 1 {
|
||||
t.Fatalf("graph = %+v, error = %v; want supported relation", graph, err)
|
||||
}
|
||||
if got := graph.Relation[0]; got.Evidence != tc.evidence || got.Type != tc.relation {
|
||||
t.Fatalf("relation was rewritten: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphExtractorContractDoesNotChangeQueryExtraction(t *testing.T) {
|
||||
template := &types.PromptTemplateStructured{
|
||||
Description: "Extract graph",
|
||||
Examples: []types.GraphData{{Text: "old positive-only example", Node: []*types.GraphNode{{Name: "example entity"}}}},
|
||||
}
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{
|
||||
Content: `[{"entity1":"Alice","entity2":"Bob","relation":"knows"}]`, FinishReason: "stop",
|
||||
}}
|
||||
queryExtractor := NewExtractor(model, template)
|
||||
graph, err := queryExtractor.Extract(context.Background(), "Alice knows Bob")
|
||||
if err != nil || len(graph.Relation) != 1 {
|
||||
t.Fatalf("query extraction = %+v, %v", graph, err)
|
||||
}
|
||||
if strings.Contains(model.messages[0].Content, "Source evidence for persisted graph relations") {
|
||||
t.Fatal("query extraction received graph-only instructions")
|
||||
}
|
||||
graphExtractor := NewGraphExtractor(model, template)
|
||||
if _, err := graphExtractor.Extract(context.Background(), "Alice knows Bob"); err == nil {
|
||||
t.Fatal("persisted graph accepted a relation without evidence")
|
||||
}
|
||||
if len(template.Examples) != 1 || len(graphExtractor.template.Examples) != 0 {
|
||||
t.Fatal("graph extraction mutated shared examples or kept ungrounded examples")
|
||||
}
|
||||
if !strings.Contains(model.messages[0].Content, "Source evidence for persisted graph relations") {
|
||||
t.Fatal("graph extraction is missing its evidence instructions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphExtractorRejectsPartialAndMalformedResults(t *testing.T) {
|
||||
for _, response := range []string{
|
||||
`[{"entity":"Alice"},{"unexpected":"ignored relationship"}]`,
|
||||
`[{"entity":42}]`,
|
||||
`[{"entity":"Alice","entity1":"Alice","entity2":"Bob","relation":"knows"}]`,
|
||||
`[{"entity":"Alice","entity2":"Bob","relation":"knows","evidence":"Alice knows Bob"}]`,
|
||||
`[{"entity1":"Alice","entity2":"Bob","evidence":"Alice knows Bob"}]`,
|
||||
`[{"entity1":"Alice","entity2":"Bob","relation":42,"evidence":"Alice knows Bob"}]`,
|
||||
`[{"entity1":"Alice","entity2":"Bob","relation":"knows","evidence":42}]`,
|
||||
`[{"entity":"Alice"},{"entity1":"Alice","entity2":"Bob","relation":"knows","evidence":"Alice knows Bob"},{"entity1":"Bob","entity2":"Charlie","relation":"knows","evidence":"invented"}]`,
|
||||
} {
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: response, FinishReason: "stop"}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), "Alice knows Bob")
|
||||
if err == nil || graph != nil {
|
||||
t.Fatalf("accepted partial or malformed result: %+v, %v", graph, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphExtractorRejectsUnknownLabelsWithoutDroppingRelations(t *testing.T) {
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{
|
||||
Content: `[{"entity1":"Alice","entity2":"Bob","relation":"invented","evidence":"Alice knows Bob"}]`, FinishReason: "stop",
|
||||
}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{
|
||||
Description: "Use these types: %s", Tags: []string{"knows"},
|
||||
})
|
||||
graph, err := extractor.Extract(context.Background(), "Alice knows Bob")
|
||||
if graph != nil || err == nil || !strings.Contains(err.Error(), "outside the configured relation types") {
|
||||
t.Fatalf("graph = %+v, err = %v", graph, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphExtractorCorrectsValidationOnce(t *testing.T) {
|
||||
const source = "〔 注意事项 〗 不得用于治疗疾病 。"
|
||||
const repaired = `[{"entity1":"本品","entity2":"治疗疾病","relation":"不适用于","evidence":"不得用于治疗疾病"}]`
|
||||
for _, invalid := range []string{
|
||||
`[{"entity":"本品","entity2":"治疗疾病","relation":"不适用于","evidence":"不得用于治疗疾病"}]`,
|
||||
`[{"entity1":"本品","entity2":"治疗疾病","relation":"不适用于","evidence":"〖 注意事项 〗 不得用于治疗疾病 。"}]`,
|
||||
`[{"entity1":"本品","entity2":"治疗疾病","relation":"适用于","evidence":"用于治疗疾病"}]`,
|
||||
} {
|
||||
model := &extractionResponseChat{responses: []*types.ChatResponse{
|
||||
{Content: invalid, FinishReason: "stop"}, {Content: repaired, FinishReason: "stop"},
|
||||
}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 1 || model.calls != 2 {
|
||||
t.Fatalf("correction = %+v, %v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
if graph.Relation[0].Type != "不适用于" || graph.Relation[0].Evidence != "不得用于治疗疾病" {
|
||||
t.Fatalf("corrected relation changed: %+v", graph.Relation[0])
|
||||
}
|
||||
if len(model.messages) != 4 || model.messages[2].Content != invalid ||
|
||||
!strings.Contains(model.messages[3].Content, "failed validation") {
|
||||
t.Fatal("correction did not include rejected output and validation feedback")
|
||||
}
|
||||
}
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{
|
||||
Content: `[{"entity1":"本品","entity2":"治疗疾病","relation":"适用于","evidence":"用于治疗疾病"}]`, FinishReason: "stop",
|
||||
}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if graph != nil || err == nil || model.calls != 2 {
|
||||
t.Fatalf("invalid correction was accepted or retried indefinitely: %+v, %v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
model.responses = []*types.ChatResponse{model.response, {Content: "[]", FinishReason: "stop"}}
|
||||
model.calls = 0
|
||||
graph, err = extractor.Extract(context.Background(), source)
|
||||
if graph != nil || err == nil || !strings.Contains(err.Error(), "no extracted content") {
|
||||
t.Fatalf("empty correction hid a validation failure: %+v, %v", graph, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormater_ParseGraph_FenceVariants exercises the JSON parsing path used
|
||||
// by the graph extraction pipeline against the LLM response shapes that
|
||||
// caused issue #1113. Each case feeds a raw LLM response string to
|
||||
// Formater.ParseGraph and asserts the resulting graph data, or that the
|
||||
// error path is preserved for genuinely invalid input.
|
||||
func TestFormater_ParseGraph_FenceVariants(t *testing.T) {
|
||||
const validJSON = `[
|
||||
{"entity": "Alice", "entity_attributes": ["person"]},
|
||||
{"entity": "Bob", "entity_attributes": ["person"]},
|
||||
{"entity1": "Alice", "entity2": "Bob", "relation": "knows"}
|
||||
]`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantNodes int
|
||||
wantRels int
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "wrapped in ```json fence",
|
||||
input: "```json\n" + validJSON + "\n```",
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
name: "wrapped in plain ``` fence (no language tag)",
|
||||
input: "```\n" + validJSON + "\n```",
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
name: "no fences at all (raw JSON)",
|
||||
input: validJSON,
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
name: "leading prose then ```json fence",
|
||||
input: "Here is the extracted graph:\n\n```json\n" + validJSON + "\n```",
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
name: "trailing prose after closing fence",
|
||||
input: "```json\n" + validJSON + "\n```\n\nHope this helps!",
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
name: "extra surrounding whitespace and newlines",
|
||||
input: "\n\n ```json\n\n" + validJSON + "\n\n``` \n",
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
// Issue #1113 Pattern 3: LLM hit max_tokens, no closing fence.
|
||||
// The response is structurally a JSON array we can still parse.
|
||||
name: "truncated response, opening ```json fence with no closer",
|
||||
input: "```json\n" + validJSON,
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
// Issue #1113 Pattern 1: bare backticks/markdown around JSON
|
||||
// without a well-formed fence pair.
|
||||
name: "stray backticks around JSON",
|
||||
input: "`" + validJSON + "`",
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
name: "JSON object embedded in prose (single dict)",
|
||||
input: "Result: {\"entity\": \"Alice\", \"entity_attributes\": [\"person\"]} -- end.",
|
||||
wantNodes: 1,
|
||||
wantRels: 0,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
errContains: "empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " \n\t ",
|
||||
wantErr: true,
|
||||
errContains: "empty",
|
||||
},
|
||||
{
|
||||
name: "fenced but body is invalid JSON",
|
||||
input: "```json\nnot json at all\n```",
|
||||
wantErr: true,
|
||||
errContains: "parse",
|
||||
},
|
||||
{
|
||||
name: "no recoverable JSON, only prose",
|
||||
input: "Sorry, I cannot extract a graph from this text.",
|
||||
wantErr: true,
|
||||
errContains: "parse",
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := NewFormater()
|
||||
graph, err := f.ParseGraph(ctx, tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil (graph=%+v)", graph)
|
||||
}
|
||||
if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
|
||||
t.Fatalf("error %q does not contain %q", err.Error(), tc.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if graph == nil {
|
||||
t.Fatalf("expected non-nil graph")
|
||||
}
|
||||
if got := len(graph.Node); got != tc.wantNodes {
|
||||
t.Errorf("nodes: got %d, want %d (graph=%+v)", got, tc.wantNodes, graph)
|
||||
}
|
||||
if got := len(graph.Relation); got != tc.wantRels {
|
||||
t.Errorf("relations: got %d, want %d (graph=%+v)", got, tc.wantRels, graph)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractJSONLike covers the JSON-substring extraction helper in
|
||||
// isolation. The helper is used by the fallback path in extractContent when
|
||||
// fences are missing or malformed.
|
||||
func TestExtractJSONLike(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"plain object", `{"a": 1}`, `{"a": 1}`},
|
||||
{"plain array", `[1, 2, 3]`, `[1, 2, 3]`},
|
||||
{"object in prose", `noise {"a": 1} tail`, `{"a": 1}`},
|
||||
{"array preferred when first", `tail [1] {"a":1}`, `[1]`},
|
||||
{"object preferred when first", `tail {"a":1} [1]`, `{"a":1}`},
|
||||
{"nested braces", `{"a": {"b": [1,2]}}`, `{"a": {"b": [1,2]}}`},
|
||||
{"brace inside string literal", `{"a": "}{not real}"}`, `{"a": "}{not real}"}`},
|
||||
{"escaped quote inside string", `{"a": "he said \"hi\""}`, `{"a": "he said \"hi\""}`},
|
||||
{"unbalanced object returns empty", `{"a": 1`, ""},
|
||||
{"no json", `just words`, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := extractJSONLike(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("extractJSONLike(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripFencesAndExtract focuses on the fence-recovery helper used as a
|
||||
// last resort when the main fence regex fails. Behavior must remain
|
||||
// conservative: return an empty string when nothing plausible can be
|
||||
// recovered, so the caller can fall through to existing behavior.
|
||||
func TestStripFencesAndExtract(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
format FormatType
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "open fence with json tag and no close",
|
||||
in: "```json\n{\"a\":1}",
|
||||
format: FormatTypeJSON,
|
||||
want: `{"a":1}`,
|
||||
},
|
||||
{
|
||||
name: "open fence with no tag and no close",
|
||||
in: "```\n[1,2]",
|
||||
format: FormatTypeJSON,
|
||||
want: `[1,2]`,
|
||||
},
|
||||
{
|
||||
name: "well-formed fence still recovers body",
|
||||
in: "```json\n{\"a\":1}\n```",
|
||||
format: FormatTypeJSON,
|
||||
want: `{"a":1}`,
|
||||
},
|
||||
{
|
||||
name: "no fence but embedded json object",
|
||||
in: "Sure! {\"a\":1} done.",
|
||||
format: FormatTypeJSON,
|
||||
want: `{"a":1}`,
|
||||
},
|
||||
{
|
||||
name: "no fence and no json",
|
||||
in: "just prose",
|
||||
format: FormatTypeJSON,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
in: "",
|
||||
format: FormatTypeJSON,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := stripFencesAndExtract(tc.in, tc.format)
|
||||
if got != tc.want {
|
||||
t.Errorf("stripFencesAndExtract(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsLikelyLanguageTag guards the heuristic used to drop language-tag
|
||||
// lines after an opening fence in the recovery path.
|
||||
func TestIsLikelyLanguageTag(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"json", true},
|
||||
{"yaml", true},
|
||||
{"yml", true},
|
||||
{"go", true},
|
||||
{"c++", true},
|
||||
{"objective-c", true},
|
||||
{"", false},
|
||||
{"this is not a tag", false},
|
||||
{`{"a":1}`, false},
|
||||
{strings.Repeat("a", 17), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := isLikelyLanguageTag(tc.in); got != tc.want {
|
||||
t.Errorf("isLikelyLanguageTag(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// PluginFilterTopK is a plugin that filters search results to keep only the top K items
|
||||
type PluginFilterTopK struct{}
|
||||
|
||||
// NewPluginFilterTopK creates a new instance of PluginFilterTopK and registers it with the event manager
|
||||
func NewPluginFilterTopK(eventManager *EventManager) *PluginFilterTopK {
|
||||
res := &PluginFilterTopK{}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types that this plugin responds to
|
||||
func (p *PluginFilterTopK) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.FILTER_TOP_K}
|
||||
}
|
||||
|
||||
// OnEvent handles the FILTER_TOP_K event by filtering results to keep only the top K items
|
||||
// It can filter MergeResult, RerankResult, or SearchResult depending on which is available
|
||||
func (p *PluginFilterTopK) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
return next()
|
||||
}
|
||||
pipelineInfo(ctx, "FilterTopK", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"top_k": chatManage.RerankTopK,
|
||||
"merge_cnt": len(chatManage.MergeResult),
|
||||
"rerank_cnt": len(chatManage.RerankResult),
|
||||
"search_cnt": len(chatManage.SearchResult),
|
||||
})
|
||||
|
||||
filterTopK := func(searchResult []*types.SearchResult, topK int) []*types.SearchResult {
|
||||
sortSearchResultsDeterministically(searchResult)
|
||||
if topK > 0 && len(searchResult) > topK {
|
||||
pipelineInfo(ctx, "FilterTopK", "filter", map[string]interface{}{
|
||||
"before": len(searchResult),
|
||||
"after": topK,
|
||||
})
|
||||
searchResult = searchResult[:topK]
|
||||
}
|
||||
return searchResult
|
||||
}
|
||||
|
||||
if len(chatManage.MergeResult) > 0 {
|
||||
chatManage.MergeResult = filterTopK(chatManage.MergeResult, chatManage.RerankTopK)
|
||||
} else if len(chatManage.RerankResult) > 0 {
|
||||
chatManage.RerankResult = filterTopK(chatManage.RerankResult, chatManage.RerankTopK)
|
||||
} else if len(chatManage.SearchResult) > 0 {
|
||||
chatManage.SearchResult = filterTopK(chatManage.SearchResult, chatManage.RerankTopK)
|
||||
} else {
|
||||
pipelineWarn(ctx, "FilterTopK", "skip", map[string]interface{}{
|
||||
"reason": "no_results",
|
||||
})
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "FilterTopK", "output", map[string]interface{}{
|
||||
"merge_cnt": len(chatManage.MergeResult),
|
||||
"rerank_cnt": len(chatManage.RerankResult),
|
||||
"search_cnt": len(chatManage.SearchResult),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
// sortSearchResultsDeterministically restores the global relevance order after
|
||||
// merge stages group results through maps. Stable tie-breakers keep identical
|
||||
// requests reproducible before TopK truncation.
|
||||
func sortSearchResultsDeterministically(results []*types.SearchResult) {
|
||||
sort.SliceStable(results, func(i, j int) bool {
|
||||
left, right := results[i], results[j]
|
||||
if left == nil || right == nil {
|
||||
return left != nil
|
||||
}
|
||||
if left.Score != right.Score {
|
||||
return left.Score > right.Score
|
||||
}
|
||||
if left.KnowledgeID != right.KnowledgeID {
|
||||
return left.KnowledgeID < right.KnowledgeID
|
||||
}
|
||||
if left.ChunkType != right.ChunkType {
|
||||
return left.ChunkType < right.ChunkType
|
||||
}
|
||||
if left.ChunkIndex != right.ChunkIndex {
|
||||
return left.ChunkIndex < right.ChunkIndex
|
||||
}
|
||||
return left.ID < right.ID
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPluginFilterTopKSortsMergeResultsBeforeTruncation(t *testing.T) {
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{RerankTopK: 3},
|
||||
PipelineState: types.PipelineState{
|
||||
MergeResult: []*types.SearchResult{
|
||||
{ID: "low", KnowledgeID: "doc-c", Score: 0.2},
|
||||
{ID: "high", KnowledgeID: "doc-a", Score: 0.9},
|
||||
{ID: "medium", KnowledgeID: "doc-b", Score: 0.5},
|
||||
{ID: "second", KnowledgeID: "doc-d", Score: 0.8},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
plugin := &PluginFilterTopK{}
|
||||
err := plugin.OnEvent(
|
||||
context.Background(),
|
||||
types.FILTER_TOP_K,
|
||||
chatManage,
|
||||
func() *PluginError { return nil },
|
||||
)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Len(t, chatManage.MergeResult, 3)
|
||||
assert.Equal(t, []string{"high", "second", "medium"}, searchResultIDs(chatManage.MergeResult))
|
||||
}
|
||||
|
||||
func TestPluginFilterTopKUsesDeterministicTieBreakers(t *testing.T) {
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{RerankTopK: 10},
|
||||
PipelineState: types.PipelineState{
|
||||
MergeResult: []*types.SearchResult{
|
||||
{ID: "chunk-b", KnowledgeID: "doc-b", ChunkType: "text", StartAt: 10, EndAt: 20, Score: 0.8},
|
||||
{ID: "chunk-c", KnowledgeID: "doc-a", ChunkType: "summary", StartAt: 0, EndAt: 10, Score: 0.8},
|
||||
{ID: "chunk-a", KnowledgeID: "doc-a", ChunkType: "text", StartAt: 0, EndAt: 10, Score: 0.8},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
plugin := &PluginFilterTopK{}
|
||||
err := plugin.OnEvent(
|
||||
context.Background(),
|
||||
types.FILTER_TOP_K,
|
||||
chatManage,
|
||||
func() *PluginError { return nil },
|
||||
)
|
||||
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, []string{"chunk-c", "chunk-a", "chunk-b"}, searchResultIDs(chatManage.MergeResult))
|
||||
}
|
||||
|
||||
func searchResultIDs(results []*types.SearchResult) []string {
|
||||
ids := make([]string, 0, len(results))
|
||||
for _, result := range results {
|
||||
ids = append(ids, result.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// PluginIntoChatMessage handles the transformation of search results into chat messages
|
||||
type PluginIntoChatMessage struct {
|
||||
messageService interfaces.MessageService
|
||||
}
|
||||
|
||||
// NewPluginIntoChatMessage creates and registers a new PluginIntoChatMessage instance
|
||||
func NewPluginIntoChatMessage(eventManager *EventManager, messageService interfaces.MessageService) *PluginIntoChatMessage {
|
||||
res := &PluginIntoChatMessage{messageService: messageService}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginIntoChatMessage) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.INTO_CHAT_MESSAGE}
|
||||
}
|
||||
|
||||
// OnEvent processes the INTO_CHAT_MESSAGE event to format chat message content
|
||||
func (p *PluginIntoChatMessage) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
pipelineInfo(ctx, "IntoChatMessage", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"merge_result_cnt": len(chatManage.MergeResult),
|
||||
"template_len": len(chatManage.SummaryConfig.ContextTemplate),
|
||||
})
|
||||
|
||||
// Separate FAQ and document results when FAQ priority is enabled
|
||||
var faqResults, docResults []*types.SearchResult
|
||||
var hasHighConfidenceFAQ bool
|
||||
|
||||
if chatManage.FAQPriorityEnabled {
|
||||
for _, result := range chatManage.MergeResult {
|
||||
if result.ChunkType == string(types.ChunkTypeFAQ) {
|
||||
faqResults = append(faqResults, result)
|
||||
// Check if this FAQ has high confidence (above direct answer threshold)
|
||||
if result.Score >= chatManage.FAQDirectAnswerThreshold && !hasHighConfidenceFAQ {
|
||||
hasHighConfidenceFAQ = true
|
||||
pipelineInfo(ctx, "IntoChatMessage", "high_confidence_faq", map[string]interface{}{
|
||||
"chunk_id": result.ID,
|
||||
"score": fmt.Sprintf("%.4f", result.Score),
|
||||
"threshold": chatManage.FAQDirectAnswerThreshold,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
docResults = append(docResults, result)
|
||||
}
|
||||
}
|
||||
pipelineInfo(ctx, "IntoChatMessage", "faq_separation", map[string]interface{}{
|
||||
"faq_count": len(faqResults),
|
||||
"doc_count": len(docResults),
|
||||
"has_high_confidence": hasHighConfidenceFAQ,
|
||||
})
|
||||
}
|
||||
|
||||
// 验证用户查询的安全性
|
||||
safeQuery, isValid := utils.ValidateInput(chatManage.Query)
|
||||
if !isValid {
|
||||
pipelineWarn(ctx, "IntoChatMessage", "invalid_query", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
return ErrTemplateExecute.WithError(fmt.Errorf("user query contains invalid content"))
|
||||
}
|
||||
|
||||
// Intent-based no-search path: no retrieval results, but still render
|
||||
// through the context template so runtime metadata (current_time, etc.) is injected.
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
userContent := safeQuery
|
||||
if rewrite := strings.TrimSpace(chatManage.RewriteQuery); rewrite != "" {
|
||||
if safeRewrite, ok := utils.ValidateInput(rewrite); ok {
|
||||
userContent = safeRewrite
|
||||
} else {
|
||||
pipelineWarn(ctx, "IntoChatMessage", "invalid_rewrite_query_fallback", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
if chatManage.ImageDescription != "" && !chatManage.ChatModelSupportsVision {
|
||||
userContent += "\n\n[用户上传图片内容]\n" + chatManage.ImageDescription
|
||||
}
|
||||
if chatManage.QuotedContext != "" {
|
||||
userContent += "\n\n" + chatManage.QuotedContext
|
||||
}
|
||||
// Inject attachment content (documents, audio transcripts, etc.)
|
||||
if len(chatManage.Attachments) > 0 {
|
||||
userContent += chatManage.Attachments.BuildPrompt()
|
||||
}
|
||||
|
||||
if tpl := chatManage.SummaryConfig.ContextTemplate; tpl != "" {
|
||||
chatManage.UserContent = types.RenderPromptPlaceholders(tpl, types.PlaceholderValues{
|
||||
"query": userContent,
|
||||
"contexts": "",
|
||||
"language": chatManage.Language,
|
||||
})
|
||||
} else {
|
||||
chatManage.UserContent = userContent
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "IntoChatMessage", "no_search_with_template", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_content_len": len(chatManage.UserContent),
|
||||
"has_template": chatManage.SummaryConfig.ContextTemplate != "",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
var contextsBuilder strings.Builder
|
||||
|
||||
// Collect unique document metadata (title + description), once per knowledge
|
||||
allResults := chatManage.MergeResult
|
||||
if chatManage.FAQPriorityEnabled && len(faqResults) > 0 {
|
||||
allResults = append(faqResults, docResults...)
|
||||
}
|
||||
docHeader := buildDocumentHeader(allResults)
|
||||
if docHeader != "" {
|
||||
contextsBuilder.WriteString(docHeader)
|
||||
contextsBuilder.WriteString("\n")
|
||||
}
|
||||
|
||||
// Build contexts string based on FAQ priority strategy
|
||||
if chatManage.FAQPriorityEnabled && len(faqResults) > 0 {
|
||||
contextsBuilder.WriteString("<source type=\"faq\" priority=\"high\">\n")
|
||||
for i, result := range faqResults {
|
||||
passage := getEnrichedPassageForChat(ctx, result)
|
||||
if hasHighConfidenceFAQ && i == 0 {
|
||||
contextsBuilder.WriteString(fmt.Sprintf("<context id=\"FAQ-%d\" match=\"exact\">%s</context>\n", i+1, passage))
|
||||
} else {
|
||||
contextsBuilder.WriteString(fmt.Sprintf("<context id=\"FAQ-%d\">%s</context>\n", i+1, passage))
|
||||
}
|
||||
}
|
||||
contextsBuilder.WriteString("</source>\n")
|
||||
|
||||
if len(docResults) > 0 {
|
||||
contextsBuilder.WriteString("<source type=\"document\" priority=\"supplementary\">\n")
|
||||
for i, result := range docResults {
|
||||
passage := getEnrichedPassageForChat(ctx, result)
|
||||
contextsBuilder.WriteString(fmt.Sprintf("<context id=\"DOC-%d\">%s</context>\n", i+1, passage))
|
||||
}
|
||||
contextsBuilder.WriteString("</source>")
|
||||
}
|
||||
} else {
|
||||
for i, result := range chatManage.MergeResult {
|
||||
passage := getEnrichedPassageForChat(ctx, result)
|
||||
if i > 0 {
|
||||
contextsBuilder.WriteString("\n")
|
||||
}
|
||||
contextsBuilder.WriteString(fmt.Sprintf("<context id=\"%d\">%s</context>", i+1, passage))
|
||||
}
|
||||
}
|
||||
|
||||
chatManage.RenderedContexts = contextsBuilder.String()
|
||||
|
||||
// Replace placeholders in context template
|
||||
userContent := types.RenderPromptPlaceholders(chatManage.SummaryConfig.ContextTemplate, types.PlaceholderValues{
|
||||
"query": safeQuery,
|
||||
"contexts": chatManage.RenderedContexts,
|
||||
"language": chatManage.Language,
|
||||
})
|
||||
|
||||
// Append image description as text fallback only when the chat model cannot
|
||||
// process images directly. Vision-capable models see images via MultiContent.
|
||||
if chatManage.ImageDescription != "" && !chatManage.ChatModelSupportsVision {
|
||||
userContent += "\n\n[用户上传图片内容]\n" + chatManage.ImageDescription
|
||||
}
|
||||
if chatManage.QuotedContext != "" {
|
||||
userContent += "\n\n" + chatManage.QuotedContext
|
||||
}
|
||||
// Inject attachment content (documents, audio transcripts, etc.)
|
||||
if len(chatManage.Attachments) > 0 {
|
||||
userContent += chatManage.Attachments.BuildPrompt()
|
||||
}
|
||||
|
||||
// Set formatted content back to chat management
|
||||
chatManage.UserContent = userContent
|
||||
pipelineInfo(ctx, "IntoChatMessage", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_content_len": len(chatManage.UserContent),
|
||||
"faq_priority": chatManage.FAQPriorityEnabled,
|
||||
"intent": chatManage.Intent,
|
||||
"image_description": chatManage.ImageDescription,
|
||||
"chat_model_supports_vision": chatManage.ChatModelSupportsVision,
|
||||
})
|
||||
|
||||
p.persistRenderedContent(ctx, chatManage)
|
||||
return next()
|
||||
}
|
||||
|
||||
// persistRenderedContent asynchronously writes the RAG-augmented UserContent back
|
||||
// to the user message so that subsequent conversation turns can see the full
|
||||
// retrieval context in history.
|
||||
func (p *PluginIntoChatMessage) persistRenderedContent(ctx context.Context, chatManage *types.ChatManage) {
|
||||
if chatManage.UserMessageID == "" || chatManage.UserContent == "" {
|
||||
pipelineInfo(ctx, "IntoChatMessage", "persist_rendered_content_skip", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_message_id": chatManage.UserMessageID,
|
||||
"has_user_content": chatManage.UserContent != "",
|
||||
"reason": "empty_id_or_content",
|
||||
})
|
||||
return
|
||||
}
|
||||
if chatManage.UserContent == chatManage.Query {
|
||||
return
|
||||
}
|
||||
pipelineInfo(ctx, "IntoChatMessage", "persist_rendered_content", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_message_id": chatManage.UserMessageID,
|
||||
"rendered_content_len": len(chatManage.UserContent),
|
||||
})
|
||||
bgCtx := context.WithoutCancel(ctx)
|
||||
go func() {
|
||||
if err := p.messageService.UpdateMessageRenderedContent(
|
||||
bgCtx, chatManage.SessionID, chatManage.UserMessageID, chatManage.UserContent,
|
||||
); err != nil {
|
||||
pipelineWarn(bgCtx, "IntoChatMessage", "persist_rendered_content_error", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_message_id": chatManage.UserMessageID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// buildDocumentHeader generates a document metadata section listing each unique
|
||||
// knowledge document (by KnowledgeID) with its title and description.
|
||||
// Returns an empty string when no meaningful metadata is available.
|
||||
func buildDocumentHeader(results []*types.SearchResult) string {
|
||||
type docMeta struct {
|
||||
title string
|
||||
description string
|
||||
metadata string
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var docs []docMeta
|
||||
|
||||
for _, r := range results {
|
||||
if r.KnowledgeID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[r.KnowledgeID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[r.KnowledgeID] = struct{}{}
|
||||
|
||||
title := r.KnowledgeTitle
|
||||
if title == "" {
|
||||
title = r.KnowledgeFilename
|
||||
}
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
docs = append(docs, docMeta{
|
||||
title: title,
|
||||
description: r.KnowledgeDescription,
|
||||
metadata: r.KnowledgeCustomMetadata,
|
||||
})
|
||||
}
|
||||
|
||||
if len(docs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("<documents>\n")
|
||||
for _, d := range docs {
|
||||
b.WriteString("<document>\n")
|
||||
b.WriteString(fmt.Sprintf("<title>%s</title>\n", html.EscapeString(d.title)))
|
||||
if d.description != "" {
|
||||
b.WriteString(fmt.Sprintf("<description>%s</description>\n", html.EscapeString(d.description)))
|
||||
}
|
||||
if d.metadata != "" {
|
||||
b.WriteString(fmt.Sprintf("<metadata>%s</metadata>\n", html.EscapeString(d.metadata)))
|
||||
}
|
||||
b.WriteString("</document>\n")
|
||||
}
|
||||
b.WriteString("</documents>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// getEnrichedPassageForChat 合并Content和ImageInfo的文本内容,为聊天消息准备
|
||||
func getEnrichedPassageForChat(ctx context.Context, result *types.SearchResult) string {
|
||||
// 如果没有图片信息,直接返回内容
|
||||
if result.Content == "" && result.ImageInfo == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 如果只有内容,没有图片信息
|
||||
if result.ImageInfo == "" {
|
||||
return result.Content
|
||||
}
|
||||
|
||||
// 处理图片信息并与内容合并
|
||||
return enrichContentWithImageInfo(ctx, result.Content, result.ImageInfo)
|
||||
}
|
||||
|
||||
// enrichContentWithImageInfo delegates to the shared searchutil implementation.
|
||||
func enrichContentWithImageInfo(_ context.Context, content string, imageInfoJSON string) string {
|
||||
return searchutil.EnrichContentWithImageInfoForChat(content, imageInfoJSON)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestBuildDocumentHeaderEscapesCustomMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
header := buildDocumentHeader([]*types.SearchResult{{
|
||||
KnowledgeID: "knowledge-1",
|
||||
KnowledgeTitle: "Doc <title>",
|
||||
KnowledgeDescription: "desc & more",
|
||||
KnowledgeCustomMetadata: "region: Shanghai\nnote: </metadata><chunk>",
|
||||
}})
|
||||
|
||||
if strings.Contains(header, "<title>Doc <title></title>") {
|
||||
t.Fatalf("title was not escaped: %q", header)
|
||||
}
|
||||
if !strings.Contains(header, "Doc <title>") {
|
||||
t.Fatalf("expected escaped title, got %q", header)
|
||||
}
|
||||
if !strings.Contains(header, "</metadata><chunk>") {
|
||||
t.Fatalf("expected escaped metadata payload, got %q", header)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
type PluginLoadHistory struct {
|
||||
messageService interfaces.MessageService
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewPluginLoadHistory(eventManager *EventManager,
|
||||
messageService interfaces.MessageService,
|
||||
config *config.Config,
|
||||
) *PluginLoadHistory {
|
||||
res := &PluginLoadHistory{
|
||||
messageService: messageService,
|
||||
config: config,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
func (p *PluginLoadHistory) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.LOAD_HISTORY}
|
||||
}
|
||||
|
||||
func (p *PluginLoadHistory) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
// chatManage.MaxRounds == 0 means multi-turn is explicitly disabled
|
||||
// (e.g. by a custom agent with MultiTurnEnabled=false). Skip loading so
|
||||
// history doesn't leak into the LLM context. We do NOT fall back to the
|
||||
// global Conversation.MaxRounds default here, otherwise the disable flag
|
||||
// would be silently overridden.
|
||||
if chatManage.MaxRounds <= 0 {
|
||||
pipelineInfo(ctx, "LoadHistory", "skipped", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"reason": "multi_turn_disabled",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
maxRounds := chatManage.MaxRounds
|
||||
|
||||
pipelineInfo(ctx, "LoadHistory", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"max_rounds": maxRounds,
|
||||
})
|
||||
|
||||
historyList, err := loadAndProcessHistory(ctx, p.messageService, chatManage.SessionID, maxRounds, maxRounds*2+10)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "LoadHistory", "history_fetch", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
chatManage.History = historyList
|
||||
|
||||
pipelineInfo(ctx, "LoadHistory", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"history_rounds": len(historyList),
|
||||
"max_rounds": maxRounds,
|
||||
})
|
||||
|
||||
return next()
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// Affinity boost bounds.
|
||||
//
|
||||
// The multiplier is capped well below the wiki boost because the signal is
|
||||
// weaker: a document appearing in past answers means the retriever kept picking
|
||||
// it, not that the user found it useful. The boost is meant to break ties
|
||||
// between comparable passages, never to drag an irrelevant document to the top
|
||||
// of a question it has nothing to do with.
|
||||
const (
|
||||
affinityMaxBoost = 1.15
|
||||
affinityFullHits = 8.0
|
||||
affinityMinHits = types.MemoryDocAffinityMinHits
|
||||
affinityMaxLookup = 200
|
||||
)
|
||||
|
||||
// PluginMemoryAffinity prefers documents this person's answers keep drawing on.
|
||||
//
|
||||
// This exists because personalising the answer prompt while retrieving exactly
|
||||
// the same passages for everyone is the shallow half of a memory feature. In a
|
||||
// knowledge-base product the durable per-person signal is which material they
|
||||
// actually work from, and the reranker is where it belongs.
|
||||
//
|
||||
// The table it reads is written by the same feature that reads it. The previous
|
||||
// attempt at this shipped an anchor table with no consumer; the rule since is
|
||||
// that a per-person retrieval signal ships with the code that uses it.
|
||||
type PluginMemoryAffinity struct {
|
||||
memoryService interfaces.MemoryService
|
||||
}
|
||||
|
||||
// NewPluginMemoryAffinity creates and registers the affinity rerank plugin.
|
||||
func NewPluginMemoryAffinity(
|
||||
eventManager *EventManager, memoryService interfaces.MemoryService,
|
||||
) *PluginMemoryAffinity {
|
||||
p := &PluginMemoryAffinity{memoryService: memoryService}
|
||||
eventManager.Register(p)
|
||||
return p
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles.
|
||||
func (p *PluginMemoryAffinity) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHUNK_RERANK}
|
||||
}
|
||||
|
||||
// OnEvent applies the per-person document boost after reranking.
|
||||
func (p *PluginMemoryAffinity) OnEvent(
|
||||
ctx context.Context,
|
||||
eventType types.EventType,
|
||||
chatManage *types.ChatManage,
|
||||
next func() *PluginError,
|
||||
) *PluginError {
|
||||
if err := next(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.memoryService == nil || len(chatManage.RerankResult) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(chatManage.RerankResult))
|
||||
seen := make(map[string]struct{}, len(chatManage.RerankResult))
|
||||
for i := range chatManage.RerankResult {
|
||||
id := chatManage.RerankResult[i].KnowledgeID
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
if len(ids) >= affinityMaxLookup {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
affinity := p.memoryService.DocumentAffinity(ctx, ids)
|
||||
if len(affinity) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
boosted := 0
|
||||
for i := range chatManage.RerankResult {
|
||||
hits := affinity[chatManage.RerankResult[i].KnowledgeID]
|
||||
if hits < affinityMinHits {
|
||||
continue
|
||||
}
|
||||
chatManage.RerankResult[i].Score *= affinityFactor(hits)
|
||||
boosted++
|
||||
}
|
||||
if boosted == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.SliceStable(chatManage.RerankResult, func(i, j int) bool {
|
||||
return chatManage.RerankResult[i].Score > chatManage.RerankResult[j].Score
|
||||
})
|
||||
logger.Infof(ctx, "MemoryAffinity: boosted %d chunks from familiar documents", boosted)
|
||||
return nil
|
||||
}
|
||||
|
||||
// affinityFactor grows with use and saturates.
|
||||
//
|
||||
// The curve is logarithmic so the tenth reuse of a document counts for far less
|
||||
// than the second: familiarity should be a nudge that compounds slowly, not a
|
||||
// feedback loop that locks a person into the first document they ever opened.
|
||||
func affinityFactor(hits int) float64 {
|
||||
if hits < affinityMinHits {
|
||||
return 1
|
||||
}
|
||||
ratio := math.Log1p(float64(hits)) / math.Log1p(affinityFullHits)
|
||||
if ratio > 1 {
|
||||
ratio = 1
|
||||
}
|
||||
return 1 + (affinityMaxBoost-1)*ratio
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A document appearing in past answers means the retriever kept picking it,
|
||||
// not that the user found it useful. The boost it earns has to stay small
|
||||
// enough to break ties between comparable passages and never large enough to
|
||||
// drag an unrelated document to the top.
|
||||
func TestAffinityBoostIsBoundedAndSaturates(t *testing.T) {
|
||||
require.Equal(t, 1.0, affinityFactor(1),
|
||||
"a single sighting is not evidence of anything")
|
||||
require.Greater(t, affinityFactor(2), 1.0)
|
||||
require.Greater(t, affinityFactor(8), affinityFactor(3))
|
||||
require.LessOrEqual(t, affinityFactor(1000), affinityMaxBoost,
|
||||
"familiarity must not become a feedback loop that locks someone in")
|
||||
|
||||
// The tenth reuse counts for far less than the second.
|
||||
require.Less(t, affinityFactor(10)-affinityFactor(8), affinityFactor(3)-affinityFactor(2))
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// emitMemoryRecalled tells the client which memories this turn saw. Best
|
||||
// effort: an event failure must not stop the answer.
|
||||
func emitMemoryRecalled(
|
||||
ctx context.Context, bus types.EventBusInterface, sessionID string, used types.UsedMemories,
|
||||
) {
|
||||
if bus == nil || len(used) == 0 {
|
||||
return
|
||||
}
|
||||
if err := bus.Emit(ctx, types.Event{
|
||||
Type: types.EventType(event.EventMemoryRecalled),
|
||||
SessionID: sessionID,
|
||||
Data: event.MemoryRecalledData{Memories: used},
|
||||
}); err != nil {
|
||||
pipelineWarn(ctx, "MemoryRecall", "emit_failed", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// PluginMemoryRecall injects the user's long-term memory into the turn.
|
||||
//
|
||||
// It runs after LOAD_HISTORY and before retrieval so the memory is available
|
||||
// to every downstream stage, and it performs no model call: recall is a
|
||||
// primary-key read for the resident block plus lexical matching over a few
|
||||
// hundred short rows. A turn's first token must not wait on memory.
|
||||
type PluginMemoryRecall struct {
|
||||
memoryService interfaces.MemoryService
|
||||
}
|
||||
|
||||
func NewPluginMemoryRecall(
|
||||
eventManager *EventManager,
|
||||
memoryService interfaces.MemoryService,
|
||||
) *PluginMemoryRecall {
|
||||
res := &PluginMemoryRecall{memoryService: memoryService}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
func (p *PluginMemoryRecall) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.MEMORY_RECALL}
|
||||
}
|
||||
|
||||
func (p *PluginMemoryRecall) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
if p.memoryService == nil {
|
||||
pipelineInfo(ctx, "MemoryRecall", "skip", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"reason": "no_service",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "MemoryRecall", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"query_len": len(chatManage.Query),
|
||||
"query_preview": langfuse.TruncateRunes(chatManage.Query, 200),
|
||||
})
|
||||
|
||||
recall := p.memoryService.Recall(ctx, chatManage.Query)
|
||||
if recall.Prompt == "" {
|
||||
pipelineInfo(ctx, "MemoryRecall", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"items": 0,
|
||||
"injected": false,
|
||||
"note": "interest memories apply in query_understand, not here",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
chatManage.MemoryPrompt = recall.Prompt
|
||||
chatManage.UsedMemories = types.UsedMemoriesFromItems(recall.Items)
|
||||
emitMemoryRecalled(ctx, chatManage.EventBus, chatManage.SessionID, chatManage.UsedMemories)
|
||||
|
||||
memoryIDs := make([]string, 0, len(recall.Items))
|
||||
for _, item := range recall.Items {
|
||||
if item != nil && item.ID != "" {
|
||||
memoryIDs = append(memoryIDs, item.ID)
|
||||
}
|
||||
}
|
||||
pipelineInfo(ctx, "MemoryRecall", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"items": len(chatManage.UsedMemories),
|
||||
"injected": true,
|
||||
"prompt_runes": len([]rune(recall.Prompt)),
|
||||
"memory_ids": memoryIDs,
|
||||
"query_preview": langfuse.TruncateRunes(chatManage.Query, 200),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubMemoryService returns a fixed recall so the test can assert what the
|
||||
// pipeline does with it, rather than re-testing the memory service.
|
||||
type stubMemoryService struct {
|
||||
interfaces.MemoryService
|
||||
|
||||
recall interfaces.MemoryRecall
|
||||
lastQuery string
|
||||
recallCall int
|
||||
}
|
||||
|
||||
func (s *stubMemoryService) Recall(_ context.Context, query string) interfaces.MemoryRecall {
|
||||
s.recallCall++
|
||||
s.lastQuery = query
|
||||
return s.recall
|
||||
}
|
||||
|
||||
func (s *stubMemoryService) ScheduleExtraction(context.Context, string, string, string) {}
|
||||
|
||||
func (s *stubMemoryService) Handle(context.Context, *asynq.Task) error { return nil }
|
||||
|
||||
func newMemoryRecallPlugin(memoryService interfaces.MemoryService) *PluginMemoryRecall {
|
||||
return NewPluginMemoryRecall(NewEventManager(), memoryService)
|
||||
}
|
||||
|
||||
// TestMemoryReachesTheMessagesSentToTheModel is the assertion that matters:
|
||||
// recalling memory is pointless if it never lands in the request. It walks the
|
||||
// recall stage and then the same message assembly the completion plugins use.
|
||||
func TestMemoryReachesTheMessagesSentToTheModel(t *testing.T) {
|
||||
memoryService := &stubMemoryService{
|
||||
recall: interfaces.MemoryRecall{
|
||||
Prompt: types.WrapMemoryForPrompt("Preferences:\n- 回答请直接给结论", ""),
|
||||
Items: []*types.MemoryItem{
|
||||
{ID: "m1", Kind: types.MemoryKindPreference, Content: "回答请直接给结论"},
|
||||
},
|
||||
},
|
||||
}
|
||||
plugin := newMemoryRecallPlugin(memoryService)
|
||||
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.Query = "帮我看看这个报错"
|
||||
chatManage.UserContent = "帮我看看这个报错"
|
||||
chatManage.SummaryConfig.Prompt = "你是一个助手。"
|
||||
|
||||
nextCalled := false
|
||||
err := plugin.OnEvent(t.Context(), types.MEMORY_RECALL, chatManage, func() *PluginError {
|
||||
nextCalled = true
|
||||
return nil
|
||||
})
|
||||
require.Nil(t, err)
|
||||
require.True(t, nextCalled, "the recall stage must never stop the pipeline")
|
||||
require.Equal(t, "帮我看看这个报错", memoryService.lastQuery)
|
||||
|
||||
messages := prepareMessagesWithHistory(chatManage)
|
||||
require.NotEmpty(t, messages)
|
||||
require.Equal(t, "system", messages[0].Role)
|
||||
require.Contains(t, messages[0].Content, "回答请直接给结论",
|
||||
"the recalled memory must be present in the system message")
|
||||
require.Contains(t, messages[0].Content, "<user_memory>")
|
||||
require.True(t, strings.HasPrefix(messages[0].Content, "你是一个助手。"),
|
||||
"memory must be appended after the configured prompt, not replace it")
|
||||
}
|
||||
|
||||
func TestMemoryIsAbsentWhenNothingRecalled(t *testing.T) {
|
||||
plugin := newMemoryRecallPlugin(&stubMemoryService{})
|
||||
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.Query = "随便问点什么"
|
||||
chatManage.SummaryConfig.Prompt = "你是一个助手。"
|
||||
|
||||
require.Nil(t, plugin.OnEvent(t.Context(), types.MEMORY_RECALL, chatManage, func() *PluginError { return nil }))
|
||||
require.Empty(t, chatManage.MemoryPrompt)
|
||||
require.Empty(t, chatManage.UsedMemories)
|
||||
|
||||
messages := prepareMessagesWithHistory(chatManage)
|
||||
require.NotContains(t, messages[0].Content, "<user_memory>")
|
||||
}
|
||||
|
||||
func TestMemoryRecallEmitsWhatTheAnswerSaw(t *testing.T) {
|
||||
memoryService := &stubMemoryService{
|
||||
recall: interfaces.MemoryRecall{
|
||||
Prompt: types.WrapMemoryForPrompt("About the user:\n- 在做医疗影像", ""),
|
||||
Items: []*types.MemoryItem{
|
||||
{ID: "m1", Kind: types.MemoryKindProfile, Content: "在做医疗影像"},
|
||||
},
|
||||
},
|
||||
}
|
||||
plugin := newMemoryRecallPlugin(memoryService)
|
||||
|
||||
bus := event.NewEventBus()
|
||||
var received types.UsedMemories
|
||||
bus.On(event.EventMemoryRecalled, func(_ context.Context, evt event.Event) error {
|
||||
data, ok := evt.Data.(event.MemoryRecalledData)
|
||||
require.True(t, ok)
|
||||
received, ok = data.Memories.(types.UsedMemories)
|
||||
require.True(t, ok)
|
||||
return nil
|
||||
})
|
||||
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.Query = "继续上次的事"
|
||||
chatManage.EventBus = bus.AsEventBusInterface()
|
||||
|
||||
require.Nil(t, plugin.OnEvent(t.Context(), types.MEMORY_RECALL, chatManage, func() *PluginError { return nil }))
|
||||
|
||||
// The chat UI promises "these are the memories this answer saw", so the
|
||||
// streamed list has to be the same one that was injected.
|
||||
require.Len(t, received, 1)
|
||||
require.Equal(t, "m1", received[0].ID)
|
||||
require.Equal(t, "在做医疗影像", received[0].Content)
|
||||
require.Equal(t, received, chatManage.UsedMemories)
|
||||
}
|
||||
|
||||
func TestMemoryRecallToleratesNoService(t *testing.T) {
|
||||
plugin := newMemoryRecallPlugin(nil)
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.SummaryConfig.Prompt = "你是一个助手。"
|
||||
require.Nil(t, plugin.OnEvent(t.Context(), types.MEMORY_RECALL, chatManage, func() *PluginError { return nil }))
|
||||
require.Empty(t, chatManage.MemoryPrompt)
|
||||
}
|
||||
|
||||
func TestMemoryRecallStageIsRegisteredInThePipeline(t *testing.T) {
|
||||
// A stage nobody runs is the failure mode this whole feature has had
|
||||
// before, so assert the plugin declares the event the assembler adds.
|
||||
plugin := newMemoryRecallPlugin(&stubMemoryService{})
|
||||
require.Equal(t, []types.EventType{types.MEMORY_RECALL}, plugin.ActivationEvents())
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Phase two's claim is that memory changes what gets retrieved, not only what
|
||||
// the answer prompt says. These tests hold the two places that has to be true:
|
||||
// the query the retriever is given, and the order documents come back in.
|
||||
|
||||
type stubRetrievalMemory struct {
|
||||
stubMemoryService
|
||||
|
||||
retrieval interfaces.RetrievalContext
|
||||
affinity map[string]int
|
||||
askedFor []string
|
||||
}
|
||||
|
||||
func (s *stubRetrievalMemory) RetrievalContextFor(context.Context) interfaces.RetrievalContext {
|
||||
return s.retrieval
|
||||
}
|
||||
|
||||
func (s *stubRetrievalMemory) DocumentAffinity(_ context.Context, ids []string) map[string]int {
|
||||
s.askedFor = ids
|
||||
return s.affinity
|
||||
}
|
||||
|
||||
func TestWhoIsAskingReachesTheQueryRewriter(t *testing.T) {
|
||||
memoryService := &stubRetrievalMemory{
|
||||
retrieval: interfaces.RetrievalContext{
|
||||
Background: "在做医学影像的后端",
|
||||
Interests: []string{"医学影像分割"},
|
||||
Documents: []string{"分割模型调参手册"},
|
||||
Items: []*types.MemoryItem{
|
||||
{ID: "m1", Kind: types.MemoryKindProfile, Content: "在做医学影像的后端"},
|
||||
},
|
||||
},
|
||||
}
|
||||
plugin := &PluginQueryUnderstand{
|
||||
memoryService: memoryService,
|
||||
config: &config.Config{Conversation: &config.ConversationConfig{
|
||||
RewritePromptSystem: "改写用户的问题。",
|
||||
RewritePromptUser: "{{query}}",
|
||||
}},
|
||||
}
|
||||
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.Query = "分割怎么调参"
|
||||
|
||||
_, userPrompt := plugin.buildPrompts(t.Context(), chatManage, nil)
|
||||
|
||||
require.Contains(t, userPrompt, "在做医学影像的后端",
|
||||
"the same question means different things to different people, and only "+
|
||||
"the rewriter can act on that before retrieval runs")
|
||||
require.Contains(t, userPrompt, "医学影像分割")
|
||||
require.Contains(t, userPrompt, "分割模型调参手册")
|
||||
require.Contains(t, userPrompt, "分割怎么调参", "the question itself must survive")
|
||||
|
||||
// Conditioning the rewriter is not a recall. The background is fed in
|
||||
// whole, relevant or not, so counting it as "memories this answer used"
|
||||
// would report unrelated memories on every single turn. That list is
|
||||
// MEMORY_RECALL's to build, from what the question actually matched.
|
||||
require.Empty(t, chatManage.UsedMemories)
|
||||
}
|
||||
|
||||
func TestQueryRewriterIsUnchangedWithoutMemory(t *testing.T) {
|
||||
plugin := &PluginQueryUnderstand{
|
||||
memoryService: &stubRetrievalMemory{},
|
||||
config: &config.Config{Conversation: &config.ConversationConfig{
|
||||
RewritePromptSystem: "改写用户的问题。",
|
||||
RewritePromptUser: "{{query}}",
|
||||
}},
|
||||
}
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.Query = "分割怎么调参"
|
||||
|
||||
_, userPrompt := plugin.buildPrompts(t.Context(), chatManage, nil)
|
||||
require.NotContains(t, userPrompt, "asker_background")
|
||||
require.Empty(t, chatManage.UsedMemories)
|
||||
}
|
||||
|
||||
func TestFamiliarDocumentsRankHigher(t *testing.T) {
|
||||
memoryService := &stubRetrievalMemory{affinity: map[string]int{"doc-familiar": 8}}
|
||||
plugin := &PluginMemoryAffinity{memoryService: memoryService}
|
||||
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{RerankResult: []*types.SearchResult{
|
||||
{ID: "c1", KnowledgeID: "doc-stranger", Score: 0.80},
|
||||
{ID: "c2", KnowledgeID: "doc-familiar", Score: 0.78},
|
||||
}},
|
||||
}
|
||||
|
||||
err := plugin.OnEvent(t.Context(), types.CHUNK_RERANK, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "c2", chatManage.RerankResult[0].ID,
|
||||
"between two comparable passages, prefer the document this person works from")
|
||||
}
|
||||
|
||||
func TestAnUnrelatedDocumentIsNotDraggedToTheTop(t *testing.T) {
|
||||
// The signal is weak — it says the retriever kept picking a document, not
|
||||
// that the user found it useful — so it must never overturn a clear
|
||||
// relevance gap.
|
||||
memoryService := &stubRetrievalMemory{affinity: map[string]int{"doc-familiar": 1000}}
|
||||
plugin := &PluginMemoryAffinity{memoryService: memoryService}
|
||||
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{RerankResult: []*types.SearchResult{
|
||||
{ID: "c1", KnowledgeID: "doc-relevant", Score: 0.90},
|
||||
{ID: "c2", KnowledgeID: "doc-familiar", Score: 0.40},
|
||||
}},
|
||||
}
|
||||
|
||||
err := plugin.OnEvent(t.Context(), types.CHUNK_RERANK, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "c1", chatManage.RerankResult[0].ID)
|
||||
}
|
||||
|
||||
func TestRerankIsUntouchedWithoutAffinity(t *testing.T) {
|
||||
plugin := &PluginMemoryAffinity{memoryService: &stubRetrievalMemory{}}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{RerankResult: []*types.SearchResult{
|
||||
{ID: "c1", KnowledgeID: "doc-a", Score: 0.80},
|
||||
{ID: "c2", KnowledgeID: "doc-b", Score: 0.78},
|
||||
}},
|
||||
}
|
||||
err := plugin.OnEvent(t.Context(), types.CHUNK_RERANK, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0.80, chatManage.RerankResult[0].Score)
|
||||
require.Equal(t, 0.78, chatManage.RerankResult[1].Score)
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginMerge handles merging of search result chunks
|
||||
type PluginMerge struct {
|
||||
chunkRepo interfaces.ChunkRepository
|
||||
chunkService interfaces.ChunkService // for parent chunk resolution
|
||||
}
|
||||
|
||||
// NewPluginMerge creates and registers a new PluginMerge instance
|
||||
func NewPluginMerge(eventManager *EventManager, chunkRepo interfaces.ChunkRepository, chunkService interfaces.ChunkService) *PluginMerge {
|
||||
res := &PluginMerge{
|
||||
chunkRepo: chunkRepo,
|
||||
chunkService: chunkService,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginMerge) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHUNK_MERGE}
|
||||
}
|
||||
|
||||
// OnEvent processes the CHUNK_MERGE event to merge search result chunks.
|
||||
// The merge pipeline is:
|
||||
// 1. Select input (rerank or search fallback)
|
||||
// 2. Deduplicate by ID and content signature
|
||||
// 3. Inject relevant history references
|
||||
// 4. Resolve parent chunks (child → parent content)
|
||||
// 5. Group by knowledge source + chunk type, merge sequential current bodies
|
||||
// 6. Populate FAQ answers
|
||||
// 7. Expand short contexts with neighboring chunks
|
||||
// 7.5. Re-merge sequential or contained bodies introduced by expansion
|
||||
// 8. Final deduplication (ID + signature + partial content overlap)
|
||||
func (p *PluginMerge) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
return next()
|
||||
}
|
||||
pipelineInfo(ctx, "Merge", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"candidate_cnt": len(chatManage.RerankResult),
|
||||
})
|
||||
|
||||
// Step 1: Select input
|
||||
searchResult := p.selectInputResults(ctx, chatManage)
|
||||
|
||||
// Step 2: Initial dedup
|
||||
searchResult = p.dedup(ctx, "dedup_summary", searchResult)
|
||||
|
||||
// Step 3: Inject history references
|
||||
searchResult = p.injectHistoryResults(ctx, chatManage, searchResult)
|
||||
|
||||
pipelineInfo(ctx, "Merge", "candidate_ready", map[string]interface{}{
|
||||
"chunk_cnt": len(searchResult),
|
||||
})
|
||||
|
||||
if len(searchResult) == 0 {
|
||||
pipelineWarn(ctx, "Merge", "output", map[string]interface{}{
|
||||
"chunk_cnt": 0,
|
||||
"reason": "no_candidates",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
// Step 4: Resolve parent chunks
|
||||
searchResult = p.resolveParentChunks(ctx, chatManage, searchResult)
|
||||
|
||||
// Step 5: Group by knowledge/chunkType and merge overlapping ranges
|
||||
mergedChunks := p.groupAndMergeCurrentContent(ctx, searchResult)
|
||||
|
||||
// Step 6: Populate FAQ answers
|
||||
mergedChunks = p.populateFAQAnswers(ctx, chatManage, mergedChunks)
|
||||
|
||||
// Step 7: Expand short contexts
|
||||
mergedChunks = p.expandShortContextWithNeighbors(ctx, chatManage, mergedChunks)
|
||||
|
||||
// Step 7.5: Re-merge overlapping ranges introduced by expansion
|
||||
mergedChunks = p.groupAndMergeCurrentContent(ctx, mergedChunks)
|
||||
|
||||
// Step 8: Final dedup — catches exact duplicates plus partial content overlaps
|
||||
mergedChunks = p.dedup(ctx, "final_dedup", mergedChunks)
|
||||
mergedChunks = removePartialOverlaps(ctx, mergedChunks)
|
||||
|
||||
chatManage.MergeResult = mergedChunks
|
||||
return next()
|
||||
}
|
||||
|
||||
// selectInputResults picks rerank results if available, falling back to search
|
||||
// results sorted by score descending.
|
||||
func (p *PluginMerge) selectInputResults(ctx context.Context, chatManage *types.ChatManage) []*types.SearchResult {
|
||||
if len(chatManage.RerankResult) > 0 {
|
||||
return chatManage.RerankResult
|
||||
}
|
||||
pipelineWarn(ctx, "Merge", "fallback", map[string]interface{}{
|
||||
"reason": "empty_rerank_result",
|
||||
})
|
||||
result := chatManage.SearchResult
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Score > result[j].Score
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// dedup wraps removeDuplicateResults with before/after logging.
|
||||
func (p *PluginMerge) dedup(ctx context.Context, label string, results []*types.SearchResult) []*types.SearchResult {
|
||||
before := len(results)
|
||||
out := removeDuplicateResults(results)
|
||||
if len(out) < before {
|
||||
pipelineInfo(ctx, "Merge", label, map[string]interface{}{
|
||||
"before": before,
|
||||
"after": len(out),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// injectHistoryResults appends relevant history references to the current results
|
||||
// and deduplicates the combined set.
|
||||
func (p *PluginMerge) injectHistoryResults(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
current []*types.SearchResult,
|
||||
) []*types.SearchResult {
|
||||
historyResults := filterHistoryResults(ctx, chatManage, current)
|
||||
if len(historyResults) == 0 {
|
||||
return current
|
||||
}
|
||||
pipelineInfo(ctx, "Merge", "history_inject", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"history_hits": len(historyResults),
|
||||
})
|
||||
combined := append(current, historyResults...)
|
||||
return removeDuplicateResults(combined)
|
||||
}
|
||||
|
||||
// groupAndMergeCurrentContent groups chunks by KnowledgeID + ChunkType, then joins
|
||||
// sequential current bodies without consulting source character offsets.
|
||||
func (p *PluginMerge) groupAndMergeCurrentContent(ctx context.Context, results []*types.SearchResult) []*types.SearchResult {
|
||||
// Group by KnowledgeID → ChunkType
|
||||
knowledgeGroup := make(map[string]map[string][]*types.SearchResult)
|
||||
for _, chunk := range results {
|
||||
if _, ok := knowledgeGroup[chunk.KnowledgeID]; !ok {
|
||||
knowledgeGroup[chunk.KnowledgeID] = make(map[string][]*types.SearchResult)
|
||||
}
|
||||
knowledgeGroup[chunk.KnowledgeID][chunk.ChunkType] = append(
|
||||
knowledgeGroup[chunk.KnowledgeID][chunk.ChunkType], chunk,
|
||||
)
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Merge", "group_summary", map[string]interface{}{
|
||||
"knowledge_cnt": len(knowledgeGroup),
|
||||
})
|
||||
|
||||
// Flatten into independent (knowledgeID, chunks) work units for parallel merge.
|
||||
type mergeUnit struct {
|
||||
knowledgeID string
|
||||
chunks []*types.SearchResult
|
||||
}
|
||||
var units []mergeUnit
|
||||
for knowledgeID, chunkGroup := range knowledgeGroup {
|
||||
for _, chunks := range chunkGroup {
|
||||
units = append(units, mergeUnit{knowledgeID: knowledgeID, chunks: chunks})
|
||||
}
|
||||
}
|
||||
|
||||
groupResults := ParallelMap(units, 0, func(_ int, u mergeUnit) []*types.SearchResult {
|
||||
pipelineInfo(ctx, "Merge", "group_process", map[string]interface{}{
|
||||
"knowledge_id": u.knowledgeID,
|
||||
"chunk_cnt": len(u.chunks),
|
||||
})
|
||||
|
||||
sort.Slice(u.chunks, func(i, j int) bool {
|
||||
if u.chunks[i].ChunkIndex == u.chunks[j].ChunkIndex {
|
||||
return u.chunks[i].ID < u.chunks[j].ID
|
||||
}
|
||||
return u.chunks[i].ChunkIndex < u.chunks[j].ChunkIndex
|
||||
})
|
||||
|
||||
grouped := p.mergeSequentialChunks(ctx, u.knowledgeID, u.chunks)
|
||||
|
||||
pipelineInfo(ctx, "Merge", "group_output", map[string]interface{}{
|
||||
"knowledge_id": u.knowledgeID,
|
||||
"merged_chunks": len(grouped),
|
||||
})
|
||||
return grouped
|
||||
})
|
||||
|
||||
var mergedChunks []*types.SearchResult
|
||||
for _, g := range groupResults {
|
||||
mergedChunks = append(mergedChunks, g...)
|
||||
}
|
||||
|
||||
// Global sort restores relevance order after map-based grouping.
|
||||
// n is typically < 100, O(n log n) is negligible here.
|
||||
sortSearchResultsDeterministically(mergedChunks)
|
||||
|
||||
pipelineInfo(ctx, "Merge", "output", map[string]interface{}{
|
||||
"merged_total": len(mergedChunks),
|
||||
})
|
||||
return mergedChunks
|
||||
}
|
||||
|
||||
// resolveParentChunks expands parent-child retrieval results with current
|
||||
// parent_text context. Text children and image grandchildren share the same
|
||||
// behavior, while image Markdown is scoped to the matched text child by
|
||||
// durable URLs rather than parser coordinates.
|
||||
func (p *PluginMerge) resolveParentChunks(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
results []*types.SearchResult,
|
||||
) []*types.SearchResult {
|
||||
if len(results) == 0 || p.chunkRepo == nil {
|
||||
return results
|
||||
}
|
||||
|
||||
tenantID, _ := types.TenantIDFromContext(ctx)
|
||||
if tenantID == 0 && chatManage != nil {
|
||||
tenantID = chatManage.TenantID
|
||||
}
|
||||
if tenantID == 0 {
|
||||
pipelineWarn(ctx, "Merge", "parent_resolve_skip", map[string]interface{}{
|
||||
"reason": "missing_tenant",
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
// Collect unique parent chunk IDs
|
||||
parentIDs := make(map[string]struct{})
|
||||
for _, r := range results {
|
||||
if r.ParentChunkID != "" {
|
||||
parentIDs[r.ParentChunkID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if len(parentIDs) == 0 {
|
||||
return results
|
||||
}
|
||||
|
||||
// Batch fetch parent chunks
|
||||
ids := make([]string, 0, len(parentIDs))
|
||||
for id := range parentIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
parentChunks, err := p.chunkRepo.ListChunksByID(ctx, tenantID, ids)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "parent_resolve_failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
parentMap := make(map[string]*types.Chunk, len(parentChunks))
|
||||
for _, c := range parentChunks {
|
||||
parentMap[c.ID] = c
|
||||
}
|
||||
|
||||
// Image hits have an image -> text -> parent_text chain. Fetch the
|
||||
// grandparent only for those results so they retain parent-child context
|
||||
// without using editable StartAt/EndAt coordinates.
|
||||
imageTextParentIDs := make(map[string]struct{})
|
||||
for _, r := range results {
|
||||
if r.ChunkType == string(types.ChunkTypeImageOCR) || r.ChunkType == string(types.ChunkTypeImageCaption) {
|
||||
imageTextParentIDs[r.ParentChunkID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(imageTextParentIDs) > 0 {
|
||||
grandparentIDs := make([]string, 0)
|
||||
grandparentSeen := make(map[string]struct{})
|
||||
for _, parent := range parentChunks {
|
||||
if _, needed := imageTextParentIDs[parent.ID]; !needed {
|
||||
continue
|
||||
}
|
||||
if parent.ParentChunkID == "" || parent.ChunkType != types.ChunkTypeText {
|
||||
continue
|
||||
}
|
||||
if _, already := parentMap[parent.ParentChunkID]; already {
|
||||
continue
|
||||
}
|
||||
if _, already := grandparentSeen[parent.ParentChunkID]; already {
|
||||
continue
|
||||
}
|
||||
grandparentSeen[parent.ParentChunkID] = struct{}{}
|
||||
grandparentIDs = append(grandparentIDs, parent.ParentChunkID)
|
||||
}
|
||||
if len(grandparentIDs) > 0 {
|
||||
grandparents, fetchErr := p.chunkRepo.ListChunksByID(ctx, tenantID, grandparentIDs)
|
||||
if fetchErr != nil {
|
||||
pipelineWarn(ctx, "Merge", "grandparent_fetch_failed", map[string]interface{}{
|
||||
"error": fetchErr.Error(),
|
||||
})
|
||||
} else {
|
||||
for _, grandparent := range grandparents {
|
||||
parentMap[grandparent.ID] = grandparent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-fetch image_info scoped to matched text children only.
|
||||
textChildIDs := collectScopedTextChildIDs(results, parentMap)
|
||||
var scopedImageInfo map[string]string
|
||||
if len(textChildIDs) > 0 {
|
||||
scopedImageInfo = searchutil.CollectImageInfoByChunkIDs(ctx, p.chunkRepo, tenantID, textChildIDs)
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
if r.ParentChunkID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch r.ChunkType {
|
||||
case string(types.ChunkTypeText):
|
||||
// text → parent_text: expand to full parent for surrounding context
|
||||
// (the core parent-child value). Scope ImageInfo to this child only so
|
||||
// image-heavy parents do not inject every sibling page's OCR/Caption.
|
||||
parent, ok := parentMap[r.ParentChunkID]
|
||||
if !ok || parent.Content == "" || parent.ChunkType != types.ChunkTypeParentText {
|
||||
continue
|
||||
}
|
||||
pipelineInfo(ctx, "Merge", "parent_resolve", map[string]interface{}{
|
||||
"child_id": r.ID,
|
||||
"parent_id": r.ParentChunkID,
|
||||
"child_len": runeLen(r.Content),
|
||||
"parent_len": runeLen(parent.Content),
|
||||
"scoped_img": true,
|
||||
})
|
||||
assignScopedImageInfo(r, scopedImageInfo, r.ID)
|
||||
parentContent := searchutil.PruneMarkdownImagesByImageInfo(parent.Content, r.ImageInfo)
|
||||
r.Content = searchutil.JoinChunkContent(parentContent, r.Content, "\n\n")
|
||||
r.ContentRewritten = true
|
||||
if !containsID(r.SubChunkID, r.ID) {
|
||||
r.SubChunkID = append(r.SubChunkID, r.ID)
|
||||
}
|
||||
|
||||
case string(types.ChunkTypeImageOCR), string(types.ChunkTypeImageCaption):
|
||||
textParent, ok := parentMap[r.ParentChunkID]
|
||||
if !ok || textParent.Content == "" || textParent.ChunkType != types.ChunkTypeText {
|
||||
continue
|
||||
}
|
||||
hitImageInfo := r.ImageInfo
|
||||
contentSource := textParent
|
||||
if textParent.ParentChunkID != "" {
|
||||
if grandparent, found := parentMap[textParent.ParentChunkID]; found &&
|
||||
grandparent.ChunkType == types.ChunkTypeParentText && grandparent.Content != "" {
|
||||
contentSource = grandparent
|
||||
}
|
||||
}
|
||||
r.Content = textParent.Content
|
||||
r.ChunkIndex = textParent.ChunkIndex
|
||||
r.ContentRewritten = true
|
||||
assignScopedImageInfo(r, scopedImageInfo, textParent.ID)
|
||||
if r.ImageInfo == "" && hitImageInfo != "" {
|
||||
r.ImageInfo = searchutil.FilterImageInfoByContentURLs(textParent.Content, hitImageInfo)
|
||||
}
|
||||
textContent := searchutil.PruneMarkdownImagesByImageInfo(textParent.Content, r.ImageInfo)
|
||||
parentContent := searchutil.PruneMarkdownImagesByImageInfo(contentSource.Content, r.ImageInfo)
|
||||
r.Content = searchutil.JoinChunkContent(parentContent, textContent, "\n\n")
|
||||
r.ContentRewritten = true
|
||||
pipelineInfo(ctx, "Merge", "image_parent_resolve", map[string]interface{}{
|
||||
"child_id": r.ID,
|
||||
"child_type": r.ChunkType,
|
||||
"text_id": textParent.ID,
|
||||
"parent_id": contentSource.ID,
|
||||
"match_len": runeLen(r.Content),
|
||||
"parent_len": runeLen(contentSource.Content),
|
||||
"scoped": true,
|
||||
})
|
||||
if !containsID(r.SubChunkID, r.ID) {
|
||||
r.SubChunkID = append(r.SubChunkID, r.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// collectScopedTextChildIDs returns text chunk IDs whose image_info should be
|
||||
// loaded for parent-child merge scoping.
|
||||
func collectScopedTextChildIDs(
|
||||
results []*types.SearchResult,
|
||||
parentMap map[string]*types.Chunk,
|
||||
) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var ids []string
|
||||
for _, r := range results {
|
||||
if r.ParentChunkID == "" {
|
||||
continue
|
||||
}
|
||||
switch r.ChunkType {
|
||||
case string(types.ChunkTypeText):
|
||||
parent := parentMap[r.ParentChunkID]
|
||||
if parent == nil || parent.ChunkType != types.ChunkTypeParentText {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[r.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[r.ID] = struct{}{}
|
||||
ids = append(ids, r.ID)
|
||||
case string(types.ChunkTypeImageOCR), string(types.ChunkTypeImageCaption):
|
||||
if _, ok := seen[r.ParentChunkID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[r.ParentChunkID] = struct{}{}
|
||||
ids = append(ids, r.ParentChunkID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// assignScopedImageInfo sets ImageInfo from the per-text-child map, falling
|
||||
// back to URLs present in the result content.
|
||||
func assignScopedImageInfo(r *types.SearchResult, scoped map[string]string, textChildID string) {
|
||||
if scoped != nil {
|
||||
if info, ok := scoped[textChildID]; ok && info != "" {
|
||||
r.ImageInfo = info
|
||||
return
|
||||
}
|
||||
}
|
||||
if r.ImageInfo != "" {
|
||||
r.ImageInfo = searchutil.FilterImageInfoByContentURLs(r.Content, r.ImageInfo)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// expandShortContextWithNeighbors expands the short context with neighbors
|
||||
func (p *PluginMerge) expandShortContextWithNeighbors(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
results []*types.SearchResult,
|
||||
) []*types.SearchResult {
|
||||
const (
|
||||
minLen = 350
|
||||
maxLen = 850
|
||||
)
|
||||
|
||||
if len(results) == 0 || p.chunkRepo == nil {
|
||||
return results
|
||||
}
|
||||
|
||||
tenantID, _ := types.TenantIDFromContext(ctx)
|
||||
if tenantID == 0 && chatManage != nil {
|
||||
tenantID = chatManage.TenantID
|
||||
}
|
||||
if tenantID == 0 {
|
||||
pipelineWarn(ctx, "Merge", "expand_skip", map[string]interface{}{
|
||||
"reason": "missing_tenant",
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
type targetInfo struct {
|
||||
result *types.SearchResult
|
||||
}
|
||||
|
||||
targets := make([]targetInfo, 0)
|
||||
baseIDsSet := make(map[string]struct{})
|
||||
|
||||
for _, r := range results {
|
||||
if r == nil || r.ID == "" || r.Content == "" {
|
||||
continue
|
||||
}
|
||||
if r.ChunkType != string(types.ChunkTypeText) {
|
||||
continue
|
||||
}
|
||||
if runeLen(r.Content) >= minLen {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, targetInfo{result: r})
|
||||
baseIDsSet[r.ID] = struct{}{}
|
||||
pipelineInfo(ctx, "Merge", "need_expand", map[string]interface{}{
|
||||
"chunk_id": r.ID,
|
||||
"content": r.Content,
|
||||
"chunk_type": r.ChunkType,
|
||||
"len": runeLen(r.Content),
|
||||
})
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
return results
|
||||
}
|
||||
|
||||
baseIDs := make([]string, 0, len(baseIDsSet))
|
||||
for id := range baseIDsSet {
|
||||
baseIDs = append(baseIDs, id)
|
||||
}
|
||||
|
||||
chunkMap := make(map[string]*types.Chunk, len(baseIDs))
|
||||
chunks, err := p.chunkRepo.ListChunksByID(ctx, tenantID, baseIDs)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "expand_list_base_failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return results
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
chunkMap[chunk.ID] = chunk
|
||||
}
|
||||
|
||||
neighborIDsSet := make(map[string]struct{})
|
||||
for _, chunk := range chunkMap {
|
||||
if chunk == nil {
|
||||
continue
|
||||
}
|
||||
if chunk.PreChunkID != "" {
|
||||
if _, exists := chunkMap[chunk.PreChunkID]; !exists {
|
||||
neighborIDsSet[chunk.PreChunkID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if chunk.NextChunkID != "" {
|
||||
if _, exists := chunkMap[chunk.NextChunkID]; !exists {
|
||||
neighborIDsSet[chunk.NextChunkID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(neighborIDsSet) > 0 {
|
||||
neighborIDs := make([]string, 0, len(neighborIDsSet))
|
||||
for id := range neighborIDsSet {
|
||||
neighborIDs = append(neighborIDs, id)
|
||||
}
|
||||
neighbors, err := p.chunkRepo.ListChunksByID(ctx, tenantID, neighborIDs)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "expand_list_neighbor_failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
for _, chunk := range neighbors {
|
||||
chunkMap[chunk.ID] = chunk
|
||||
pipelineInfo(ctx, "Merge", "expand_list_neighbor_success", map[string]interface{}{
|
||||
"neighbor_chunk_id": chunk.ID,
|
||||
"neighbor_content": chunk.Content,
|
||||
"neighbor_chunk_type": chunk.ChunkType,
|
||||
"neighbor_len": runeLen(chunk.Content),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
res := target.result
|
||||
p.fetchChunksIfMissing(ctx, tenantID, chunkMap, res.ID)
|
||||
baseChunk := chunkMap[res.ID]
|
||||
if baseChunk == nil || baseChunk.Content == "" || baseChunk.ChunkType != types.ChunkTypeText {
|
||||
continue
|
||||
}
|
||||
|
||||
prevContent := ""
|
||||
nextContent := ""
|
||||
prevIDs := []string{}
|
||||
nextIDs := []string{}
|
||||
|
||||
prevCursor := baseChunk.PreChunkID
|
||||
nextCursor := baseChunk.NextChunkID
|
||||
|
||||
p.fetchChunksIfMissing(ctx, tenantID, chunkMap, prevCursor, nextCursor)
|
||||
|
||||
if prevCursor != "" {
|
||||
if prevChunk := chunkMap[prevCursor]; prevChunk != nil && prevChunk.KnowledgeID == baseChunk.KnowledgeID {
|
||||
prevContent = prevChunk.Content
|
||||
prevIDs = append(prevIDs, prevChunk.ID)
|
||||
prevCursor = prevChunk.PreChunkID
|
||||
} else {
|
||||
prevCursor = ""
|
||||
}
|
||||
}
|
||||
|
||||
if nextCursor != "" {
|
||||
if nextChunk := chunkMap[nextCursor]; nextChunk != nil && nextChunk.KnowledgeID == baseChunk.KnowledgeID {
|
||||
nextContent = nextChunk.Content
|
||||
nextIDs = append(nextIDs, nextChunk.ID)
|
||||
nextCursor = nextChunk.NextChunkID
|
||||
} else {
|
||||
nextCursor = ""
|
||||
}
|
||||
}
|
||||
|
||||
var merged string
|
||||
for {
|
||||
merged = mergeOrderedContent(prevContent, baseChunk.Content, nextContent, maxLen)
|
||||
if merged == "" {
|
||||
break
|
||||
}
|
||||
if runeLen(merged) >= minLen {
|
||||
break
|
||||
}
|
||||
if prevCursor == "" && nextCursor == "" {
|
||||
break
|
||||
}
|
||||
|
||||
expanded := false
|
||||
if prevCursor != "" {
|
||||
p.fetchChunksIfMissing(ctx, tenantID, chunkMap, prevCursor)
|
||||
if prevChunk := chunkMap[prevCursor]; prevChunk != nil &&
|
||||
prevChunk.KnowledgeID == baseChunk.KnowledgeID {
|
||||
prevContent = searchutil.JoinChunkContent(prevChunk.Content, prevContent, "\n\n")
|
||||
prevIDs = append([]string{prevChunk.ID}, prevIDs...)
|
||||
prevCursor = prevChunk.PreChunkID
|
||||
expanded = true
|
||||
} else {
|
||||
prevCursor = ""
|
||||
}
|
||||
}
|
||||
|
||||
merged = mergeOrderedContent(prevContent, baseChunk.Content, nextContent, maxLen)
|
||||
if runeLen(merged) >= minLen {
|
||||
break
|
||||
}
|
||||
|
||||
if nextCursor != "" {
|
||||
p.fetchChunksIfMissing(ctx, tenantID, chunkMap, nextCursor)
|
||||
if nextChunk := chunkMap[nextCursor]; nextChunk != nil &&
|
||||
nextChunk.KnowledgeID == baseChunk.KnowledgeID {
|
||||
nextContent = searchutil.JoinChunkContent(nextContent, nextChunk.Content, "\n\n")
|
||||
nextIDs = append(nextIDs, nextChunk.ID)
|
||||
nextCursor = nextChunk.NextChunkID
|
||||
expanded = true
|
||||
} else {
|
||||
nextCursor = ""
|
||||
}
|
||||
}
|
||||
|
||||
if !expanded {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if merged == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
beforeLen := runeLen(res.Content)
|
||||
res.Content = merged
|
||||
res.ContentRewritten = true
|
||||
|
||||
for _, id := range prevIDs {
|
||||
if id != "" && !containsID(res.SubChunkID, id) {
|
||||
res.SubChunkID = append(res.SubChunkID, id)
|
||||
}
|
||||
}
|
||||
for _, id := range nextIDs {
|
||||
if id != "" && !containsID(res.SubChunkID, id) {
|
||||
res.SubChunkID = append(res.SubChunkID, id)
|
||||
}
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Merge", "expand_short_chunk", map[string]interface{}{
|
||||
"chunk_id": res.ID,
|
||||
"prev_ids": prevIDs,
|
||||
"next_ids": nextIDs,
|
||||
"before_len": beforeLen,
|
||||
"after_len": runeLen(res.Content),
|
||||
"base_content": baseChunk.Content,
|
||||
"after_content": res.Content,
|
||||
"chunk_type": res.ChunkType,
|
||||
"remaining_prev": prevCursor,
|
||||
"remaining_next": nextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// runeLen returns the length of a string in runes
|
||||
func runeLen(s string) int {
|
||||
return len([]rune(s))
|
||||
}
|
||||
|
||||
// mergeOrderedContent merges ordered content
|
||||
func mergeOrderedContent(prev, base, next string, maxLen int) string {
|
||||
content := base
|
||||
if prev != "" {
|
||||
content = searchutil.JoinChunkContent(prev, content, "\n\n")
|
||||
}
|
||||
if next != "" {
|
||||
content = searchutil.JoinChunkContent(content, next, "\n\n")
|
||||
}
|
||||
runes := []rune(content)
|
||||
if len(runes) > maxLen {
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func containsID(ids []string, target string) bool {
|
||||
for _, id := range ids {
|
||||
if id == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *PluginMerge) fetchChunksIfMissing(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
chunkMap map[string]*types.Chunk,
|
||||
chunkIDs ...string,
|
||||
) {
|
||||
missing := make([]string, 0, len(chunkIDs))
|
||||
for _, id := range chunkIDs {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := chunkMap[id]; !exists {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
chunks, err := p.chunkRepo.ListChunksByID(ctx, tenantID, missing)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "expand_fetch_missing_failed", map[string]interface{}{
|
||||
"missing_cnt": len(missing),
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
found := make(map[string]struct{}, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
chunkMap[chunk.ID] = chunk
|
||||
found[chunk.ID] = struct{}{}
|
||||
}
|
||||
|
||||
for _, id := range missing {
|
||||
if _, ok := found[id]; !ok {
|
||||
chunkMap[id] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
type expandChunkRepo struct {
|
||||
interfaces.ChunkRepository
|
||||
chunks map[string]*types.Chunk
|
||||
children map[string][]*types.Chunk
|
||||
}
|
||||
|
||||
func (r *expandChunkRepo) ListChunksByParentIDs(
|
||||
_ context.Context, _ uint64, parentIDs []string,
|
||||
) ([]*types.Chunk, error) {
|
||||
var chunks []*types.Chunk
|
||||
for _, parentID := range parentIDs {
|
||||
chunks = append(chunks, r.children[parentID]...)
|
||||
}
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func (r *expandChunkRepo) ListChunksByID(
|
||||
_ context.Context, _ uint64, ids []string,
|
||||
) ([]*types.Chunk, error) {
|
||||
chunks := make([]*types.Chunk, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if chunk := r.chunks[id]; chunk != nil {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
}
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func TestExpandShortContextKeepsSourceCoordinates(t *testing.T) {
|
||||
repo := &expandChunkRepo{chunks: map[string]*types.Chunk{
|
||||
"prev": {ID: "prev", KnowledgeID: "doc", ChunkType: types.ChunkTypeText, Content: "edited previous body", NextChunkID: "base"},
|
||||
"base": {ID: "base", KnowledgeID: "doc", ChunkType: types.ChunkTypeText, Content: "edited base body", PreChunkID: "prev", NextChunkID: "next", StartAt: 100, EndAt: 120},
|
||||
"next": {ID: "next", KnowledgeID: "doc", ChunkType: types.ChunkTypeText, Content: "edited next body", PreChunkID: "base"},
|
||||
}}
|
||||
plugin := &PluginMerge{chunkRepo: repo}
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
|
||||
result := &types.SearchResult{
|
||||
ID: "base", KnowledgeID: "doc", ChunkType: string(types.ChunkTypeText),
|
||||
Content: "edited base body", StartAt: 100, EndAt: 120,
|
||||
}
|
||||
|
||||
got := plugin.expandShortContextWithNeighbors(ctx, &types.ChatManage{}, []*types.SearchResult{result})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("result count = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].StartAt != 100 || got[0].EndAt != 120 {
|
||||
t.Fatalf("source coordinates changed to [%d,%d)", got[0].StartAt, got[0].EndAt)
|
||||
}
|
||||
for _, want := range []string{"edited previous body", "edited base body", "edited next body"} {
|
||||
if !strings.Contains(got[0].Content, want) {
|
||||
t.Fatalf("expanded content missing %q: %q", want, got[0].Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// populateFAQAnswers populates FAQ answers for the search results
|
||||
func (p *PluginMerge) populateFAQAnswers(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
results []*types.SearchResult,
|
||||
) []*types.SearchResult {
|
||||
if len(results) == 0 || p.chunkRepo == nil {
|
||||
return results
|
||||
}
|
||||
|
||||
tenantID, _ := types.TenantIDFromContext(ctx)
|
||||
if tenantID == 0 && chatManage != nil {
|
||||
tenantID = chatManage.TenantID
|
||||
}
|
||||
if tenantID == 0 {
|
||||
pipelineWarn(ctx, "Merge", "faq_enrich_skip", map[string]interface{}{
|
||||
"reason": "missing_tenant",
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
chunkResultMap := make(map[string][]*types.SearchResult)
|
||||
chunkIDSet := make(map[string]struct{})
|
||||
for _, r := range results {
|
||||
if r == nil || r.ID == "" {
|
||||
continue
|
||||
}
|
||||
if r.ChunkType != string(types.ChunkTypeFAQ) {
|
||||
continue
|
||||
}
|
||||
chunkResultMap[r.ID] = append(chunkResultMap[r.ID], r)
|
||||
if _, exists := chunkIDSet[r.ID]; !exists {
|
||||
chunkIDSet[r.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if len(chunkIDSet) == 0 {
|
||||
return results
|
||||
}
|
||||
|
||||
chunkIDs := make([]string, 0, len(chunkIDSet))
|
||||
for id := range chunkIDSet {
|
||||
chunkIDs = append(chunkIDs, id)
|
||||
}
|
||||
|
||||
chunks, err := p.chunkRepo.ListChunksByID(ctx, tenantID, chunkIDs)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "faq_chunk_fetch_failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
updated := 0
|
||||
for _, chunk := range chunks {
|
||||
if chunk == nil {
|
||||
continue
|
||||
}
|
||||
meta, err := chunk.FAQMetadata()
|
||||
if err != nil || meta == nil {
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "faq_metadata_parse_failed", map[string]interface{}{
|
||||
"chunk_id": chunk.ID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
content := buildFAQAnswerContent(meta)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
for _, r := range chunkResultMap[chunk.ID] {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
r.Content = content
|
||||
updated++
|
||||
}
|
||||
}
|
||||
|
||||
if updated > 0 {
|
||||
pipelineInfo(ctx, "Merge", "faq_content_enriched", map[string]interface{}{
|
||||
"chunk_cnt": updated,
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// buildFAQAnswerContent builds the content of a FAQ answer
|
||||
func buildFAQAnswerContent(meta *types.FAQChunkMetadata) string {
|
||||
if meta == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
question := strings.TrimSpace(meta.StandardQuestion)
|
||||
answers := make([]string, 0, len(meta.Answers))
|
||||
for _, ans := range meta.Answers {
|
||||
if trimmed := strings.TrimSpace(ans); trimmed != "" {
|
||||
answers = append(answers, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
if question == "" && len(answers) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
if question != "" {
|
||||
builder.WriteString("Q: ")
|
||||
builder.WriteString(question)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(answers) > 0 {
|
||||
builder.WriteString("Answer:\n")
|
||||
for _, ans := range answers {
|
||||
builder.WriteString("- ")
|
||||
builder.WriteString(ans)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// groupAndMergeCurrentContent groups results by KnowledgeID and ChunkType
|
||||
// using two levels of Go maps. Map iteration order is non-deterministic,
|
||||
// so the concatenated output can appear in arbitrary order. This test
|
||||
// verifies that the post-merge sort restores deterministic ordering
|
||||
// (score desc, then KnowledgeID, ChunkType).
|
||||
func TestGroupAndMergeOverlapping_DeterministicOrdering(t *testing.T) {
|
||||
plugin := &PluginMerge{}
|
||||
|
||||
t.Run("sorts by score descending across groups", func(t *testing.T) {
|
||||
// Same KnowledgeID, different ChunkTypes -> separate inner-map
|
||||
// buckets whose iteration order is randomized.
|
||||
chunks := []*types.SearchResult{
|
||||
{ID: "low", KnowledgeID: "kb-001", ChunkType: "text", StartAt: 0, EndAt: 100, Score: 0.4},
|
||||
{ID: "high", KnowledgeID: "kb-001", ChunkType: "summary", StartAt: 200, EndAt: 300, Score: 0.9},
|
||||
{ID: "mid", KnowledgeID: "kb-001", ChunkType: "parent_text", StartAt: 400, EndAt: 500, Score: 0.6},
|
||||
}
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
|
||||
require.Len(t, results, 3)
|
||||
assert.Equal(t, "high", results[0].ID, "highest score (0.9) must be first")
|
||||
assert.Equal(t, "mid", results[1].ID, "middle score (0.6) must be second")
|
||||
assert.Equal(t, "low", results[2].ID, "lowest score (0.4) must be last")
|
||||
})
|
||||
|
||||
t.Run("uses KnowledgeID as first tie-breaker when scores equal", func(t *testing.T) {
|
||||
// Different KnowledgeIDs -> separate outer-map buckets whose
|
||||
// iteration order is randomized.
|
||||
chunks := []*types.SearchResult{
|
||||
{ID: "ab", KnowledgeID: "kb-ab", ChunkType: "text", StartAt: 0, EndAt: 50, Score: 0.8},
|
||||
{ID: "aa", KnowledgeID: "kb-aa", ChunkType: "text", StartAt: 0, EndAt: 50, Score: 0.8},
|
||||
}
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
require.Len(t, results, 2)
|
||||
assert.Equal(t, "aa", results[0].ID, "same score, kb-aa < kb-ab")
|
||||
})
|
||||
|
||||
t.Run("merged chunk uses max score and is sorted among other groups", func(t *testing.T) {
|
||||
// Two overlapping text chunks merge into one (score = max(0.5,0.7) = 0.7),
|
||||
// then the merged result competes with a higher-scored summary chunk
|
||||
// across the inner-map boundary.
|
||||
chunks := []*types.SearchResult{
|
||||
{ID: "low-a", KnowledgeID: "kb-001", ChunkType: "text", ChunkIndex: 1, Content: "first body", StartAt: 0, EndAt: 50, Score: 0.5},
|
||||
{ID: "low-b", KnowledgeID: "kb-001", ChunkType: "text", ChunkIndex: 2, Content: "second body", StartAt: 30, EndAt: 80, Score: 0.7},
|
||||
{ID: "high", KnowledgeID: "kb-001", ChunkType: "summary", StartAt: 200, EndAt: 300, Score: 0.9},
|
||||
}
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
require.Len(t, results, 2, "two text chunks merged into one, plus one summary")
|
||||
assert.Equal(t, "high", results[0].ID, "summary (0.9) beats merged text (0.7)")
|
||||
})
|
||||
|
||||
t.Run("uses ChunkType as tie-breaker when score and knowledge match", func(t *testing.T) {
|
||||
// Same KnowledgeID, different ChunkTypes -> separate inner-map
|
||||
// buckets whose iteration order is randomized.
|
||||
chunks := []*types.SearchResult{
|
||||
{ID: "txt", KnowledgeID: "kb-x", ChunkType: "text", StartAt: 0, EndAt: 50, Score: 0.8},
|
||||
{ID: "sum", KnowledgeID: "kb-x", ChunkType: "summary", StartAt: 100, EndAt: 150, Score: 0.8},
|
||||
}
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
require.Len(t, results, 2)
|
||||
assert.Equal(t, "sum", results[0].ID, "same kb+score, 'summary' < 'text'")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGroupAndMergeOverlapping_IgnoresStaleEditedRanges(t *testing.T) {
|
||||
plugin := &PluginMerge{}
|
||||
chunks := []*types.SearchResult{
|
||||
{
|
||||
ID: "edited-outer", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 1,
|
||||
StartAt: 0, EndAt: 200, Content: "completely rewritten outer content", Score: 0.4,
|
||||
},
|
||||
{
|
||||
ID: "edited-inner", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 2,
|
||||
StartAt: 50, EndAt: 100, Content: "independent current inner content", Score: 0.9,
|
||||
},
|
||||
}
|
||||
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, chunks[0].Content)
|
||||
assert.Contains(t, results[0].Content, chunks[1].Content)
|
||||
assert.Equal(t, 0.9, results[0].Score)
|
||||
}
|
||||
|
||||
func TestGroupAndMergeOverlapping_DoesNotMergeNonSequentialChunksFromCoordinates(t *testing.T) {
|
||||
plugin := &PluginMerge{}
|
||||
chunks := []*types.SearchResult{
|
||||
{ID: "one", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 1, StartAt: 0, EndAt: 200, Content: "one"},
|
||||
{ID: "three", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 3, StartAt: 50, EndAt: 100, Content: "three"},
|
||||
}
|
||||
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
require.Len(t, results, 2)
|
||||
}
|
||||
|
||||
// TestGroupAndMergeOverlapping_CrossKnowledgePreservesMergeLogic verifies
|
||||
// that chunks from different KnowledgeIDs do NOT accidentally merge across
|
||||
// knowledge boundaries even when they share StartAt/EndAt ranges.
|
||||
func TestGroupAndMergeOverlapping_CrossKnowledgePreservesMergeLogic(t *testing.T) {
|
||||
plugin := &PluginMerge{}
|
||||
chunks := []*types.SearchResult{
|
||||
{ID: "a-1", KnowledgeID: "kb-a", ChunkType: "text", StartAt: 0, EndAt: 100, Score: 0.9},
|
||||
{ID: "b-1", KnowledgeID: "kb-b", ChunkType: "text", StartAt: 0, EndAt: 100, Score: 0.5},
|
||||
}
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
|
||||
require.Len(t, results, 2, "chunks from different KBs must not merge")
|
||||
assert.Equal(t, "a-1", results[0].ID, "higher score from kb-a first")
|
||||
assert.Equal(t, "b-1", results[1].ID, "lower score from kb-b second")
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// filterHistoryResults retrieves history references and filters them by
|
||||
// textual similarity to the current query. Only references that are above
|
||||
// a Jaccard similarity threshold are kept, and their scores are discounted
|
||||
// to reflect that they were not directly retrieved for the current query.
|
||||
// Results already present in currentResults (by chunk ID) are excluded.
|
||||
func filterHistoryResults(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
currentResults []*types.SearchResult,
|
||||
) []*types.SearchResult {
|
||||
const (
|
||||
// minSimilarity is the minimum Jaccard similarity between the current
|
||||
// query and a history chunk's content for it to be injected.
|
||||
minSimilarity = 0.15
|
||||
// historyScoreDiscount reduces the original score of history results
|
||||
// to rank them below freshly-retrieved results of similar relevance.
|
||||
historyScoreDiscount = 0.6
|
||||
// maxHistoryResults caps the number of history results injected to
|
||||
// avoid overwhelming the context with stale references.
|
||||
maxHistoryResults = 3
|
||||
)
|
||||
|
||||
raw := getSearchResultFromHistory(chatManage)
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build a set of chunk IDs already in current results for fast dedup
|
||||
existingIDs := make(map[string]struct{}, len(currentResults))
|
||||
for _, r := range currentResults {
|
||||
existingIDs[r.ID] = struct{}{}
|
||||
}
|
||||
|
||||
// Use RewriteQuery if available (it's the cleaned-up retrieval query),
|
||||
// otherwise fall back to the original query.
|
||||
query := chatManage.RewriteQuery
|
||||
if query == "" {
|
||||
query = chatManage.Query
|
||||
}
|
||||
queryTokens := searchutil.TokenizeSimple(query)
|
||||
|
||||
var filtered []*types.SearchResult
|
||||
for _, r := range raw {
|
||||
if _, exists := existingIDs[r.ID]; exists {
|
||||
continue
|
||||
}
|
||||
contentTokens := searchutil.TokenizeSimple(r.Content)
|
||||
sim := searchutil.Jaccard(queryTokens, contentTokens)
|
||||
if sim < minSimilarity {
|
||||
pipelineInfo(ctx, "Merge", "history_filter_drop", map[string]interface{}{
|
||||
"chunk_id": r.ID,
|
||||
"similarity": sim,
|
||||
})
|
||||
continue
|
||||
}
|
||||
r.MatchType = types.MatchTypeHistory
|
||||
r.Score = r.Score * historyScoreDiscount
|
||||
r.Metadata = ensureMetadata(r.Metadata)
|
||||
r.Metadata["history_similarity"] = strings.TrimRight(strings.TrimRight(
|
||||
fmt.Sprintf("%.4f", sim), "0"), ".")
|
||||
filtered = append(filtered, r)
|
||||
|
||||
pipelineInfo(ctx, "Merge", "history_filter_keep", map[string]interface{}{
|
||||
"chunk_id": r.ID,
|
||||
"similarity": sim,
|
||||
"new_score": r.Score,
|
||||
})
|
||||
|
||||
if len(filtered) >= maxHistoryResults {
|
||||
break
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// mergeSequentialChunks joins sequential current chunk bodies.
|
||||
// Trusted pairs use position-aware merging;
|
||||
// any pair involving edited, expanded, or stale content falls back to
|
||||
// text matching so current content is never discarded on parser coordinates.
|
||||
// Chunks MUST be pre-sorted by ChunkIndex.
|
||||
func (p *PluginMerge) mergeSequentialChunks(
|
||||
ctx context.Context,
|
||||
knowledgeID string,
|
||||
chunks []*types.SearchResult,
|
||||
) []*types.SearchResult {
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mergedGroup struct {
|
||||
result *types.SearchResult
|
||||
lastIndex int
|
||||
}
|
||||
groups := []mergedGroup{{result: chunks[0], lastIndex: chunks[0].ChunkIndex}}
|
||||
for i := 1; i < len(chunks); i++ {
|
||||
current := chunks[i]
|
||||
last := &groups[len(groups)-1]
|
||||
lastChunk := last.result
|
||||
|
||||
switch classifyMerge(lastChunk, last.lastIndex, current) {
|
||||
case mergeSeparate:
|
||||
groups = append(groups, mergedGroup{result: current, lastIndex: current.ChunkIndex})
|
||||
continue
|
||||
case mergeExtend:
|
||||
lastChunk.Content = appendTrustedContent(lastChunk.Content, current.Content, lastChunk.EndAt-current.StartAt)
|
||||
lastChunk.EndAt = current.EndAt
|
||||
recordMergedChild(ctx, knowledgeID, lastChunk, current, "image_merge")
|
||||
case mergeSubsume:
|
||||
recordMergedChild(ctx, knowledgeID, lastChunk, current, "image_merge_contained")
|
||||
case mergeJoinDistinct:
|
||||
lastChunk.Content = searchutil.JoinChunkContent(lastChunk.Content, current.Content, "\n\n")
|
||||
recordMergedChild(ctx, knowledgeID, lastChunk, current, "image_merge_contained")
|
||||
case mergeJoinText:
|
||||
lastChunk.Content = searchutil.JoinChunkContent(lastChunk.Content, current.Content, "\n\n")
|
||||
recordMergedChild(ctx, knowledgeID, lastChunk, current, "image_merge")
|
||||
}
|
||||
|
||||
// Extend the merged group's span and keep the higher score.
|
||||
if current.ChunkIndex > last.lastIndex {
|
||||
last.lastIndex = current.ChunkIndex
|
||||
}
|
||||
if current.Score > lastChunk.Score {
|
||||
lastChunk.Score = current.Score
|
||||
}
|
||||
}
|
||||
|
||||
merged := make([]*types.SearchResult, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
merged = append(merged, group.result)
|
||||
}
|
||||
|
||||
// Sort merged chunks by score (highest first)
|
||||
sort.Slice(merged, func(i, j int) bool {
|
||||
return merged[i].Score > merged[j].Score
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// appendTrustedContent joins a trusted pair using the overlap the coordinates
|
||||
// already give us, and only falls back to text search when the overlap does not
|
||||
// match character for character (HTML entities or synthetic table headers can
|
||||
// keep the length invariant while changing the body).
|
||||
//
|
||||
// The fallback matters both ways: searching for the longest suffix match would
|
||||
// otherwise mistake a repeated table row or log line for the overlap and drop
|
||||
// real content, which is exactly what the position path is meant to avoid.
|
||||
func appendTrustedContent(acc, next string, positionOverlap int) string {
|
||||
if merged, ok := searchutil.AppendWithExactOverlap(acc, next, positionOverlap); ok {
|
||||
return merged
|
||||
}
|
||||
return searchutil.AppendWithOverlap(acc, next, positionOverlap)
|
||||
}
|
||||
|
||||
// chunkTrusted reports whether a result's StartAt/EndAt can be trusted for
|
||||
// position-based merging: unedited, not pipeline-rewritten, valid range, and
|
||||
// splitter-consistent length (runeLen(Content) == EndAt-StartAt).
|
||||
func chunkTrusted(chunk *types.SearchResult) bool {
|
||||
return chunk.ContentRevision == 0 &&
|
||||
!chunk.ContentRewritten &&
|
||||
chunk.EndAt > chunk.StartAt &&
|
||||
runeLen(chunk.Content) == chunk.EndAt-chunk.StartAt
|
||||
}
|
||||
|
||||
// mergeSituation labels how the current result relates to the group's leading
|
||||
// result in document order. The classifier decides it once; the merge loop
|
||||
// switches on it without nesting.
|
||||
type mergeSituation int
|
||||
|
||||
const (
|
||||
// mergeSeparate: current is not mergeable, it starts a new group
|
||||
// (position gap, or untrusted pair that is neither text-contained nor
|
||||
// index-sequential).
|
||||
mergeSeparate mergeSituation = iota
|
||||
// mergeExtend: trusted pair with partial overlap or adjacency; the
|
||||
// non-overlapping suffix is appended with text-verified trimming.
|
||||
mergeExtend
|
||||
// mergeSubsume: trusted pair, range-contained and text-verified; current
|
||||
// is recorded without touching the merged content.
|
||||
mergeSubsume
|
||||
// mergeJoinDistinct: trusted pair, range-contained but textually distinct;
|
||||
// current is kept via a text join, breaking the length invariant so later
|
||||
// pairs degrade to the text path.
|
||||
mergeJoinDistinct
|
||||
// mergeJoinText: untrusted pair that is text-contained or index-sequential;
|
||||
// joined via pure text matching.
|
||||
mergeJoinText
|
||||
)
|
||||
|
||||
// classifyMerge labels the pair relationship so the merge loop can dispatch
|
||||
// with a flat switch. It needs the group's running lastIndex for the untrusted
|
||||
// sequentiality check.
|
||||
func classifyMerge(lastChunk *types.SearchResult, lastIndex int, current *types.SearchResult) mergeSituation {
|
||||
if chunkTrusted(lastChunk) && chunkTrusted(current) && current.StartAt >= lastChunk.StartAt {
|
||||
switch {
|
||||
case current.StartAt > lastChunk.EndAt:
|
||||
return mergeSeparate
|
||||
case current.EndAt > lastChunk.EndAt:
|
||||
return mergeExtend
|
||||
case searchutil.ContainsChunkContent(lastChunk.Content, current.Content):
|
||||
return mergeSubsume
|
||||
default:
|
||||
return mergeJoinDistinct
|
||||
}
|
||||
}
|
||||
|
||||
textContained := searchutil.ContainsChunkContent(lastChunk.Content, current.Content) ||
|
||||
searchutil.ContainsChunkContent(current.Content, lastChunk.Content)
|
||||
sequential := current.ChunkIndex == lastIndex+1
|
||||
if !textContained && !sequential {
|
||||
return mergeSeparate
|
||||
}
|
||||
return mergeJoinText
|
||||
}
|
||||
|
||||
// recordMergedChild records a merged chunk on the group result: appends its ID
|
||||
// to SubChunkID (deduplicated) and merges its ImageInfo, warning on failure.
|
||||
// warnKey distinguishes merge contexts in pipeline diagnostics.
|
||||
func recordMergedChild(ctx context.Context, knowledgeID string, target, source *types.SearchResult, warnKey string) {
|
||||
if !containsID(target.SubChunkID, source.ID) {
|
||||
target.SubChunkID = append(target.SubChunkID, source.ID)
|
||||
}
|
||||
if err := mergeImageInfo(ctx, target, source); err != nil {
|
||||
pipelineWarn(ctx, "Merge", warnKey, map[string]any{
|
||||
"knowledge_id": knowledgeID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mergeImageInfo merges ImageInfo from source into target, deduplicating by URL.
|
||||
func mergeImageInfo(ctx context.Context, target *types.SearchResult, source *types.SearchResult) error {
|
||||
if source.ImageInfo == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sourceImageInfos []types.ImageInfo
|
||||
if err := json.Unmarshal([]byte(source.ImageInfo), &sourceImageInfos); err != nil {
|
||||
pipelineWarn(ctx, "Merge", "image_unmarshal_source", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
if len(sourceImageInfos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var targetImageInfos []types.ImageInfo
|
||||
if target.ImageInfo != "" {
|
||||
if err := json.Unmarshal([]byte(target.ImageInfo), &targetImageInfos); err != nil {
|
||||
pipelineWarn(ctx, "Merge", "image_unmarshal_target", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
target.ImageInfo = source.ImageInfo
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
targetImageInfos = append(targetImageInfos, sourceImageInfos...)
|
||||
|
||||
uniqueMap := make(map[string]bool)
|
||||
uniqueImageInfos := make([]types.ImageInfo, 0, len(targetImageInfos))
|
||||
|
||||
for _, imgInfo := range targetImageInfos {
|
||||
if imgInfo.URL != "" && !uniqueMap[imgInfo.URL] {
|
||||
uniqueMap[imgInfo.URL] = true
|
||||
uniqueImageInfos = append(uniqueImageInfos, imgInfo)
|
||||
}
|
||||
}
|
||||
|
||||
mergedImageInfoJSON, err := json.Marshal(uniqueImageInfos)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "image_marshal", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
target.ImageInfo = string(mergedImageInfoJSON)
|
||||
pipelineInfo(ctx, "Merge", "image_merged", map[string]interface{}{
|
||||
"image_refs": len(uniqueImageInfos),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestResolveParentChunksUsesCurrentContentAndImageURLs(t *testing.T) {
|
||||
parent := &types.Chunk{
|
||||
ID: "parent", ChunkType: types.ChunkTypeParentText, ChunkIndex: 7,
|
||||
Content: "manually inserted prefix\n\n\n\nparent body\n\n",
|
||||
}
|
||||
imageInfo, err := json.Marshal([]types.ImageInfo{{URL: "u2", OCRText: "two"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &expandChunkRepo{
|
||||
chunks: map[string]*types.Chunk{"parent": parent},
|
||||
children: map[string][]*types.Chunk{
|
||||
"child": {
|
||||
{
|
||||
ID: "image", ParentChunkID: "child", ChunkType: types.ChunkTypeImageOCR,
|
||||
ImageInfo: string(imageInfo),
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
plugin := &PluginMerge{chunkRepo: repo}
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
|
||||
result := &types.SearchResult{
|
||||
ID: "child", KnowledgeID: "doc", ChunkType: string(types.ChunkTypeText),
|
||||
ParentChunkID: "parent", Content: "current edited child body", StartAt: 999, EndAt: 1001,
|
||||
}
|
||||
|
||||
got := plugin.resolveParentChunks(ctx, &types.ChatManage{}, []*types.SearchResult{result})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("result count = %d, want 1", len(got))
|
||||
}
|
||||
if !strings.Contains(got[0].Content, "current edited child body") {
|
||||
t.Fatalf("edited child content was lost: %q", got[0].Content)
|
||||
}
|
||||
if strings.Contains(got[0].Content, "u1") || !strings.Contains(got[0].Content, "u2") {
|
||||
t.Fatalf("parent images were not scoped by URL: %q", got[0].Content)
|
||||
}
|
||||
if got[0].StartAt != 999 || got[0].EndAt != 1001 {
|
||||
t.Fatalf("source coordinates changed to [%d,%d)", got[0].StartAt, got[0].EndAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveImageChunkKeepsGrandparentContextWithoutCoordinateSlicing(t *testing.T) {
|
||||
imageInfo, err := json.Marshal([]types.ImageInfo{{URL: "u1", OCRText: "matched image"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &expandChunkRepo{
|
||||
chunks: map[string]*types.Chunk{
|
||||
"text": {
|
||||
ID: "text", ParentChunkID: "parent", ChunkType: types.ChunkTypeText, ChunkIndex: 4,
|
||||
Content: "current edited text child\n\n", StartAt: 900, EndAt: 910,
|
||||
},
|
||||
"parent": {
|
||||
ID: "parent", ChunkType: types.ChunkTypeParentText,
|
||||
Content: "grandparent context before\n\n\n\ngrandparent context after\n\n",
|
||||
StartAt: 0, EndAt: 100,
|
||||
},
|
||||
},
|
||||
children: map[string][]*types.Chunk{
|
||||
"text": {{
|
||||
ID: "image", ParentChunkID: "text", ChunkType: types.ChunkTypeImageOCR,
|
||||
ImageInfo: string(imageInfo),
|
||||
IsEnabled: true,
|
||||
}},
|
||||
},
|
||||
}
|
||||
plugin := &PluginMerge{chunkRepo: repo}
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
|
||||
result := &types.SearchResult{
|
||||
ID: "image", KnowledgeID: "doc", ChunkType: string(types.ChunkTypeImageOCR),
|
||||
ParentChunkID: "text", Content: "matched image", ImageInfo: string(imageInfo),
|
||||
StartAt: 500, EndAt: 510,
|
||||
}
|
||||
|
||||
got := plugin.resolveParentChunks(ctx, &types.ChatManage{}, []*types.SearchResult{result})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("result count = %d, want 1", len(got))
|
||||
}
|
||||
for _, want := range []string{"grandparent context before", "grandparent context after", "current edited text child", "u1"} {
|
||||
if !strings.Contains(got[0].Content, want) {
|
||||
t.Fatalf("image result lost %q: %q", want, got[0].Content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got[0].Content, "u2") {
|
||||
t.Fatalf("sibling image leaked into image result: %q", got[0].Content)
|
||||
}
|
||||
if got[0].StartAt != 500 || got[0].EndAt != 510 {
|
||||
t.Fatalf("source coordinates changed to [%d,%d)", got[0].StartAt, got[0].EndAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopedTextChildIDs(t *testing.T) {
|
||||
parentMap := map[string]*types.Chunk{
|
||||
"parent-1": {ID: "parent-1", ChunkType: types.ChunkTypeParentText},
|
||||
"text-x": {ID: "text-x", ChunkType: types.ChunkTypeText},
|
||||
}
|
||||
results := []*types.SearchResult{
|
||||
{ID: "text-1", ChunkType: string(types.ChunkTypeText), ParentChunkID: "parent-1"},
|
||||
{ID: "img-1", ChunkType: string(types.ChunkTypeImageOCR), ParentChunkID: "text-2"},
|
||||
{ID: "text-3", ChunkType: string(types.ChunkTypeText), ParentChunkID: "text-x"}, // not parent_text
|
||||
}
|
||||
ids := collectScopedTextChildIDs(results, parentMap)
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("ids: %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignScopedImageInfo_FiltersToContentURLs(t *testing.T) {
|
||||
all, _ := json.Marshal([]types.ImageInfo{
|
||||
{URL: "u1", OCRText: "one"},
|
||||
{URL: "u2", OCRText: "two"},
|
||||
})
|
||||
r := &types.SearchResult{
|
||||
Content: "",
|
||||
ImageInfo: string(all),
|
||||
}
|
||||
assignScopedImageInfo(r, nil, "missing-child")
|
||||
var infos []types.ImageInfo
|
||||
if err := json.Unmarshal([]byte(r.ImageInfo), &infos); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(infos) != 1 || infos[0].URL != "u2" {
|
||||
t.Fatalf("filtered: %+v", infos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentChildImageHit_WindowSliceAndFilter(t *testing.T) {
|
||||
parentContent := "\n\n\n\n"
|
||||
textStart := len([]rune("\n\n"))
|
||||
textEnd := textStart + len([]rune(""))
|
||||
sliced := searchutil.SliceContentByDocumentRange(parentContent, 0, textStart, textEnd)
|
||||
if sliced != "" {
|
||||
t.Fatalf("slice: got %q", sliced)
|
||||
}
|
||||
|
||||
all, _ := json.Marshal([]types.ImageInfo{
|
||||
{URL: "u1"}, {URL: "u2"}, {URL: "u3"},
|
||||
})
|
||||
filtered := searchutil.FilterImageInfoByContentURLs(sliced, string(all))
|
||||
var infos []types.ImageInfo
|
||||
if err := json.Unmarshal([]byte(filtered), &infos); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(infos) != 1 || infos[0].URL != "u2" {
|
||||
t.Fatalf("infos: %+v", infos)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// rangeText returns deterministic text of exactly n runes so fixtures satisfy
|
||||
// the splitter invariant runeLen(Content) == EndAt-StartAt.
|
||||
func rangeText(seed rune, n int) string {
|
||||
return strings.Repeat(string(seed), n)
|
||||
}
|
||||
|
||||
// trustedResult builds a SearchResult whose coordinates are trustworthy
|
||||
// (unedited, valid range, splitter-consistent length).
|
||||
func trustedResult(id string, index, start, end int, content string) *types.SearchResult {
|
||||
return &types.SearchResult{
|
||||
ID: id, ChunkIndex: index, StartAt: start, EndAt: end, Content: content, Score: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
// mergeGrouped runs the merge stage as the pipeline does: group by knowledge
|
||||
// and chunk type, then merge sequential bodies. Observable behavior only.
|
||||
func mergeGrouped(chunks []*types.SearchResult) []*types.SearchResult {
|
||||
return (&PluginMerge{}).groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
}
|
||||
|
||||
// All fixtures below share KnowledgeID "doc", ChunkType "text" and Score 1.0,
|
||||
// so results form a single group and the deterministic post-merge sort keeps
|
||||
// their relative order by ChunkIndex.
|
||||
func docChunks(chunks ...*types.SearchResult) []*types.SearchResult {
|
||||
for _, c := range chunks {
|
||||
c.KnowledgeID = "doc"
|
||||
c.ChunkType = "text"
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedOverlapTrimsAndExtendsRange(t *testing.T) {
|
||||
// 20-rune real overlap between two trusted chunks: the merged body must
|
||||
// contain the overlap exactly once and extend the result range.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 80)+rangeText('b', 20))
|
||||
second := trustedResult("c2", 2, 80, 200, rangeText('b', 20)+rangeText('c', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, rangeText('a', 80)+rangeText('b', 20)+rangeText('c', 100), results[0].Content)
|
||||
assert.Equal(t, 200, results[0].EndAt, "merged range must extend to the second chunk")
|
||||
assert.ElementsMatch(t, []string{"c2"}, results[0].SubChunkID)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedOverlapBeyondTextSearchWindow(t *testing.T) {
|
||||
// A real overlap wider than the text matcher's 400-rune window: the position
|
||||
// path knows the exact amount, so the overlap must not survive twice.
|
||||
overlap := rangeText('o', 500)
|
||||
first := trustedResult("c1", 1, 0, 1000, rangeText('a', 500)+overlap)
|
||||
second := trustedResult("c2", 2, 500, 1500, overlap+rangeText('b', 500))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, rangeText('a', 500)+overlap+rangeText('b', 500), results[0].Content)
|
||||
assert.Equal(t, 1500, results[0].EndAt)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedAdjacentRepeatedTextKeepsEveryRow(t *testing.T) {
|
||||
// Adjacent trusted chunks (positional overlap 0) made of one repeating row:
|
||||
// searching for the longest suffix match would mistake the repetition for an
|
||||
// overlap and drop rows, so the exact-overlap path must concatenate verbatim.
|
||||
row := "| cell | cell |\n"
|
||||
firstBody := rangeText('x', 100) + strings.Repeat(row, 2)
|
||||
secondBody := strings.Repeat(row, 3) + rangeText('y', 60)
|
||||
first := trustedResult("c1", 1, 0, runeLen(firstBody), firstBody)
|
||||
second := trustedResult("c2", 2, runeLen(firstBody), runeLen(firstBody)+runeLen(secondBody), secondBody)
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, firstBody+secondBody, results[0].Content)
|
||||
assert.Equal(t, 5, strings.Count(results[0].Content, row), "no repeated row may be dropped")
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedOverlapTextMismatchFallsBackToTextMatch(t *testing.T) {
|
||||
// Length invariant holds but the bodies disagree inside the overlap window
|
||||
// (HTML entities, synthetic headers): the exact path must refuse and the text
|
||||
// fallback must keep both bodies rather than trim on coordinates.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 80)+rangeText('b', 20))
|
||||
second := trustedResult("c2", 2, 80, 200, rangeText('c', 20)+rangeText('d', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 20))
|
||||
assert.Contains(t, results[0].Content, rangeText('c', 20))
|
||||
assert.Contains(t, results[0].Content, rangeText('d', 100))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedAdjacentJoinsSeamlessly(t *testing.T) {
|
||||
// Adjacent trusted chunks are contiguous in the original document: the
|
||||
// merged body must be the exact concatenation, with no separator inserted.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
second := trustedResult("c2", 2, 100, 200, rangeText('b', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, rangeText('a', 100)+rangeText('b', 100), results[0].Content)
|
||||
assert.Equal(t, 200, results[0].EndAt)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedGapStartsNewGroup(t *testing.T) {
|
||||
// A real position gap means content in between is missing: chunks must
|
||||
// stay separate instead of being joined with a fabricated separator.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
second := trustedResult("c2", 2, 150, 250, rangeText('b', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 2, "position gap must not merge")
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_RewrittenResultDoesNotTrustCoordinates(t *testing.T) {
|
||||
// A result whose content was replaced by the pipeline (parent or neighbor
|
||||
// expansion) must never enter the position path, even when the replacement
|
||||
// happens to satisfy the length invariant and share boundary text: its
|
||||
// stale coordinates must not extend the merged group's range.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
rewritten := trustedResult("c2", 2, 80, 200, rangeText('a', 20)+rangeText('b', 100))
|
||||
rewritten.ContentRewritten = true
|
||||
|
||||
results := mergeGrouped(docChunks(first, rewritten))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 100))
|
||||
assert.Equal(t, 100, results[0].EndAt, "rewritten result must not extend the range via stale coordinates")
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedContainedSubsumes(t *testing.T) {
|
||||
// Range-contained and text-verified: one result whose content is the
|
||||
// outer body; the inner chunk is recorded, not duplicated.
|
||||
outer := trustedResult("outer", 1, 0, 200, rangeText('a', 200))
|
||||
inner := trustedResult("inner", 2, 50, 100, rangeText('a', 50))
|
||||
|
||||
results := mergeGrouped(docChunks(outer, inner))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, rangeText('a', 200), results[0].Content)
|
||||
assert.ElementsMatch(t, []string{"inner"}, results[0].SubChunkID)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedContainedDistinctKeepsBoth(t *testing.T) {
|
||||
// Range-contained but textually distinct: both bodies must survive; the
|
||||
// inner one must never be dropped on coordinates alone.
|
||||
outer := trustedResult("outer", 1, 0, 100, rangeText('a', 100))
|
||||
inner := trustedResult("inner", 2, 50, 60, rangeText('b', 10))
|
||||
|
||||
results := mergeGrouped(docChunks(outer, inner))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 10))
|
||||
assert.ElementsMatch(t, []string{"inner"}, results[0].SubChunkID)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_EditedPairKeepsBoth(t *testing.T) {
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
edited := &types.SearchResult{
|
||||
ID: "c2", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 2, StartAt: 80, EndAt: 200,
|
||||
Content: "rewritten body", ContentRevision: 1, Score: 1.0,
|
||||
}
|
||||
|
||||
results := mergeGrouped(docChunks(first, edited))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, "rewritten body")
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_SameLengthRewriteKeepsBoth(t *testing.T) {
|
||||
// An edit that happens to keep the splitter length invariant must still
|
||||
// never cause the rewritten body to be swallowed on coordinates.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
edited := &types.SearchResult{
|
||||
ID: "c2", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 2, StartAt: 80, EndAt: 200,
|
||||
Content: rangeText('b', 120), ContentRevision: 1, Score: 1.0,
|
||||
}
|
||||
|
||||
results := mergeGrouped(docChunks(first, edited))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 120))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_InvariantBreakKeepsBoth(t *testing.T) {
|
||||
// ContentRevision 0 but length inconsistent with the range (e.g. zero-width
|
||||
// table headers, HTML entities): both bodies must survive.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
stale := &types.SearchResult{
|
||||
ID: "c2", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 2, StartAt: 80, EndAt: 200,
|
||||
Content: "short", Score: 1.0,
|
||||
}
|
||||
|
||||
results := mergeGrouped(docChunks(first, stale))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, "short")
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_StartInversionKeepsBoth(t *testing.T) {
|
||||
// A chunk whose StartAt precedes the group's but has a higher ChunkIndex
|
||||
// contradicts document order: both bodies must survive.
|
||||
first := trustedResult("c1", 1, 100, 200, rangeText('a', 100))
|
||||
inverted := trustedResult("c2", 2, 50, 150, rangeText('b', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, inverted))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 100))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_EditedNonSequentialSeparates(t *testing.T) {
|
||||
// Edited chunks that are neither text-contained nor index-sequential stay
|
||||
// separate results.
|
||||
first := &types.SearchResult{
|
||||
ID: "c1", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 1, StartAt: 0, EndAt: 50,
|
||||
Content: rangeText('a', 50), ContentRevision: 1, Score: 1.0,
|
||||
}
|
||||
third := &types.SearchResult{
|
||||
ID: "c3", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 3, StartAt: 200, EndAt: 250,
|
||||
Content: rangeText('b', 50), ContentRevision: 1, Score: 1.0,
|
||||
}
|
||||
|
||||
results := mergeGrouped(docChunks(first, third))
|
||||
|
||||
require.Len(t, results, 2, "non-sequential edited chunks must not merge")
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_MidGroupDriftKeepsAllBodies(t *testing.T) {
|
||||
// When an overlap cannot be trimmed the merged body no longer matches its
|
||||
// range; subsequent pairs must still merge safely without losing content.
|
||||
c1 := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
c2 := trustedResult("c2", 2, 90, 200, rangeText('c', 110))
|
||||
c3 := trustedResult("c3", 3, 200, 300, rangeText('e', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(c1, c2, c3))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('c', 110))
|
||||
assert.Contains(t, results[0].Content, rangeText('e', 100))
|
||||
assert.ElementsMatch(t, []string{"c2", "c3"}, results[0].SubChunkID)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// --- IntoChatMessage tests ---
|
||||
|
||||
func TestIntoChatMessage_NoKBRetrieval(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
Query: "hello world",
|
||||
},
|
||||
PipelineState: types.PipelineState{
|
||||
Intent: types.IntentChitchat,
|
||||
},
|
||||
}
|
||||
plugin := &PluginIntoChatMessage{messageService: nil}
|
||||
nextCalled := false
|
||||
err := plugin.OnEvent(context.Background(), types.INTO_CHAT_MESSAGE, cm, func() *PluginError {
|
||||
nextCalled = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !nextCalled {
|
||||
t.Fatal("next() was not called")
|
||||
}
|
||||
if cm.UserContent != "hello world" {
|
||||
t.Errorf("UserContent: got %q, want %q", cm.UserContent, "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntoChatMessage_WithMergeResults(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
Query: "test query",
|
||||
SummaryConfig: types.SummaryConfig{
|
||||
ContextTemplate: "Question: {{query}}\n\nReferences:\n{{contexts}}",
|
||||
},
|
||||
},
|
||||
PipelineState: types.PipelineState{
|
||||
MergeResult: []*types.SearchResult{
|
||||
{Content: "chunk A content"},
|
||||
{Content: "chunk B content"},
|
||||
},
|
||||
},
|
||||
}
|
||||
plugin := &PluginIntoChatMessage{messageService: nil}
|
||||
nextCalled := false
|
||||
err := plugin.OnEvent(context.Background(), types.INTO_CHAT_MESSAGE, cm, func() *PluginError {
|
||||
nextCalled = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !nextCalled {
|
||||
t.Fatal("next() was not called")
|
||||
}
|
||||
if cm.UserContent == "" {
|
||||
t.Fatal("expected UserContent to be populated")
|
||||
}
|
||||
if !contains(cm.UserContent, "test query") {
|
||||
t.Errorf("UserContent should contain query, got: %s", cm.UserContent)
|
||||
}
|
||||
if !contains(cm.UserContent, "chunk A content") {
|
||||
t.Errorf("UserContent should contain chunk A, got: %s", cm.UserContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntoChatMessage_ImageDescriptionAppended(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
Query: "what is this?",
|
||||
ChatModelSupportsVision: false,
|
||||
},
|
||||
PipelineState: types.PipelineState{
|
||||
Intent: types.IntentChitchat,
|
||||
ImageDescription: "a cat sitting on a mat",
|
||||
},
|
||||
}
|
||||
plugin := &PluginIntoChatMessage{messageService: nil}
|
||||
_ = plugin.OnEvent(context.Background(), types.INTO_CHAT_MESSAGE, cm, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
if !contains(cm.UserContent, "a cat sitting on a mat") {
|
||||
t.Errorf("UserContent should contain image description, got: %s", cm.UserContent)
|
||||
}
|
||||
}
|
||||
|
||||
// --- PipelineBuilder tests ---
|
||||
|
||||
func TestPipelineBuilder_Basic(t *testing.T) {
|
||||
pipeline := types.NewPipelineBuilder().
|
||||
Add(types.LOAD_HISTORY).
|
||||
Add(types.CHAT_COMPLETION_STREAM).
|
||||
Build()
|
||||
|
||||
if len(pipeline) != 2 {
|
||||
t.Fatalf("expected 2 stages, got %d", len(pipeline))
|
||||
}
|
||||
if pipeline[0] != types.LOAD_HISTORY {
|
||||
t.Errorf("stage 0: got %v, want %v", pipeline[0], types.LOAD_HISTORY)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineBuilder_AddIf(t *testing.T) {
|
||||
pipeline := types.NewPipelineBuilder().
|
||||
Add(types.LOAD_HISTORY).
|
||||
AddIf(false, types.QUERY_UNDERSTAND).
|
||||
AddIf(true, types.CHAT_COMPLETION_STREAM).
|
||||
Build()
|
||||
|
||||
if len(pipeline) != 2 {
|
||||
t.Fatalf("expected 2 stages (QUERY_UNDERSTAND skipped), got %d", len(pipeline))
|
||||
}
|
||||
if pipeline[1] != types.CHAT_COMPLETION_STREAM {
|
||||
t.Errorf("stage 1: got %v, want %v", pipeline[1], types.CHAT_COMPLETION_STREAM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineBuilder_Empty(t *testing.T) {
|
||||
pipeline := types.NewPipelineBuilder().Build()
|
||||
if len(pipeline) != 0 {
|
||||
t.Fatalf("expected 0 stages, got %d", len(pipeline))
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsString(s, substr))
|
||||
}
|
||||
|
||||
func containsString(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
retrievalProgressTool = "knowledge_search"
|
||||
queryUnderstandProgressTool = "query_understand"
|
||||
|
||||
retrievalSourceKnowledge = "knowledge"
|
||||
retrievalSourceWeb = "web"
|
||||
retrievalSourceMixed = "mixed"
|
||||
)
|
||||
|
||||
// StageProgress tracks an in-flight pipeline progress tool_call.
|
||||
type StageProgress struct {
|
||||
toolCallID string
|
||||
toolName string
|
||||
}
|
||||
|
||||
// ShouldEmitQueryUnderstandProgress reports whether the query-understand stage
|
||||
// will actually run (rewrite enabled or images attached).
|
||||
func ShouldEmitQueryUnderstandProgress(chatManage *types.ChatManage) bool {
|
||||
if chatManage == nil {
|
||||
return false
|
||||
}
|
||||
return chatManage.EnableRewrite || len(chatManage.Images) > 0
|
||||
}
|
||||
|
||||
// IsConsolidatedRetrievalStage reports whether a pipeline stage belongs to the
|
||||
// single user-visible "knowledge search" progress window (search → rerank → merge).
|
||||
func IsConsolidatedRetrievalStage(stage types.EventType, chatManage *types.ChatManage) bool {
|
||||
if chatManage == nil {
|
||||
return false
|
||||
}
|
||||
switch stage {
|
||||
case types.CHUNK_SEARCH_PARALLEL, types.CHUNK_RERANK, types.CHUNK_MERGE, types.FILTER_TOP_K:
|
||||
return chatManage.NeedsRetrieval()
|
||||
case types.WEB_FETCH:
|
||||
return chatManage.WebSearchEnabled
|
||||
case types.DATA_ANALYSIS:
|
||||
return chatManage.DataAnalysisEnabled && chatManage.NeedsRetrieval()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// LastConsolidatedRetrievalStage returns the last retrieval-related stage in the
|
||||
// assembled pipeline, or empty when none apply.
|
||||
func LastConsolidatedRetrievalStage(eventList []types.EventType, chatManage *types.ChatManage) types.EventType {
|
||||
var last types.EventType
|
||||
for _, stage := range eventList {
|
||||
if IsConsolidatedRetrievalStage(stage, chatManage) {
|
||||
last = stage
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// ShouldCloseRetrievalProgress reports whether the consolidated retrieval
|
||||
// progress window must be closed after a pipeline stage. It returns true when
|
||||
// either the planned last retrieval stage completed, or a retrieval stage
|
||||
// short-circuited the pipeline with an error — including ErrSearchNothing,
|
||||
// which routes into the fallback response. Closing on the error paths prevents
|
||||
// the frontend "knowledge_search" spinner from hanging forever when the
|
||||
// pipeline early-returns before reaching the last retrieval stage.
|
||||
func ShouldCloseRetrievalProgress(stage, lastRetrievalStage types.EventType, stageErr *PluginError) bool {
|
||||
return stage == lastRetrievalStage || stageErr != nil
|
||||
}
|
||||
|
||||
// BeginRetrievalProgress emits a single pending knowledge_search tool_call.
|
||||
func BeginRetrievalProgress(ctx context.Context, chatManage *types.ChatManage) *StageProgress {
|
||||
if chatManage == nil || chatManage.EventBus == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
toolCallID := uuid.New().String()
|
||||
args := map[string]any{
|
||||
"search_source": retrievalSearchSource(chatManage),
|
||||
}
|
||||
if chatManage.RewriteQuery != "" {
|
||||
args["query"] = chatManage.RewriteQuery
|
||||
} else if chatManage.Query != "" {
|
||||
args["query"] = chatManage.Query
|
||||
}
|
||||
|
||||
_ = chatManage.EventBus.Emit(ctx, types.Event{
|
||||
Type: types.EventType(event.EventAgentToolCall),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentToolCallData{
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: retrievalProgressTool,
|
||||
Arguments: args,
|
||||
},
|
||||
})
|
||||
|
||||
return &StageProgress{toolCallID: toolCallID, toolName: retrievalProgressTool}
|
||||
}
|
||||
|
||||
// BeginQueryUnderstandProgress emits a pending query_understand tool_call.
|
||||
func BeginQueryUnderstandProgress(ctx context.Context, chatManage *types.ChatManage) *StageProgress {
|
||||
if chatManage == nil || chatManage.EventBus == nil || !ShouldEmitQueryUnderstandProgress(chatManage) {
|
||||
return nil
|
||||
}
|
||||
|
||||
toolCallID := uuid.New().String()
|
||||
args := map[string]any{}
|
||||
if chatManage.Query != "" {
|
||||
args["query"] = chatManage.Query
|
||||
}
|
||||
if len(chatManage.Images) > 0 {
|
||||
args["has_images"] = true
|
||||
}
|
||||
|
||||
_ = chatManage.EventBus.Emit(ctx, types.Event{
|
||||
Type: types.EventType(event.EventAgentToolCall),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentToolCallData{
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: queryUnderstandProgressTool,
|
||||
Arguments: args,
|
||||
},
|
||||
})
|
||||
|
||||
return &StageProgress{toolCallID: toolCallID, toolName: queryUnderstandProgressTool}
|
||||
}
|
||||
|
||||
// EndQueryUnderstandProgress emits the matching tool_result for query understanding.
|
||||
func EndQueryUnderstandProgress(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
progress *StageProgress,
|
||||
start time.Time,
|
||||
stageErr *PluginError,
|
||||
) {
|
||||
if progress == nil || chatManage == nil || chatManage.EventBus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
success := stageErr == nil
|
||||
output := ""
|
||||
if success {
|
||||
output = "已完成问题理解"
|
||||
}
|
||||
|
||||
var errMsg string
|
||||
if !success && stageErr != nil && stageErr.Err != nil {
|
||||
errMsg = stageErr.Err.Error()
|
||||
}
|
||||
|
||||
_ = chatManage.EventBus.Emit(ctx, types.Event{
|
||||
Type: types.EventType(event.EventAgentToolResult),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentToolResultData{
|
||||
ToolCallID: progress.toolCallID,
|
||||
ToolName: queryUnderstandProgressTool,
|
||||
Output: output,
|
||||
Error: errMsg,
|
||||
Success: success,
|
||||
Duration: time.Since(start).Milliseconds(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// EndRetrievalProgress emits the matching tool_result for the consolidated retrieval window.
|
||||
func EndRetrievalProgress(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
progress *StageProgress,
|
||||
start time.Time,
|
||||
stageErr *PluginError,
|
||||
) {
|
||||
if progress == nil || chatManage == nil || chatManage.EventBus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
count, docCount, webCount := retrievalResultBreakdown(chatManage)
|
||||
|
||||
// ErrSearchNothing means retrieval short-circuited the pipeline into the
|
||||
// fallback answer: nothing survived filtering, and the fallback prompt gets
|
||||
// a knowledge-base listing rather than any retrieved chunk. So no chunk
|
||||
// reached the model, and none can be cited either. Reporting the raw hits as
|
||||
// the result count is what made the timeline promise results the answer never
|
||||
// saw — and offer a row with no references to open. The raw hits stay
|
||||
// available as candidates, which is the useful part when a threshold is what
|
||||
// rejected them.
|
||||
candidateCount := 0
|
||||
if stageErr == ErrSearchNothing {
|
||||
candidateCount = count
|
||||
count, docCount, webCount = 0, 0, 0
|
||||
}
|
||||
|
||||
searchSource := retrievalSearchSource(chatManage)
|
||||
if count > 0 {
|
||||
switch {
|
||||
case docCount > 0 && webCount > 0:
|
||||
searchSource = retrievalSourceMixed
|
||||
case webCount > 0:
|
||||
searchSource = retrievalSourceWeb
|
||||
default:
|
||||
searchSource = retrievalSourceKnowledge
|
||||
}
|
||||
}
|
||||
success := stageErr == nil || stageErr == ErrSearchNothing
|
||||
output := ""
|
||||
if success {
|
||||
switch {
|
||||
case count > 0:
|
||||
output = fmt.Sprintf("检索到 %d 条相关内容", count)
|
||||
case candidateCount > 0:
|
||||
output = fmt.Sprintf("命中 %d 条候选,相关性不足,未用于回答", candidateCount)
|
||||
default:
|
||||
output = "未检索到相关内容"
|
||||
}
|
||||
}
|
||||
|
||||
var errMsg string
|
||||
if !success && stageErr != nil && stageErr.Err != nil {
|
||||
errMsg = stageErr.Err.Error()
|
||||
}
|
||||
|
||||
_ = chatManage.EventBus.Emit(ctx, types.Event{
|
||||
Type: types.EventType(event.EventAgentToolResult),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentToolResultData{
|
||||
ToolCallID: progress.toolCallID,
|
||||
ToolName: retrievalProgressTool,
|
||||
Output: output,
|
||||
Error: errMsg,
|
||||
Success: success,
|
||||
Duration: time.Since(start).Milliseconds(),
|
||||
Data: map[string]interface{}{
|
||||
"count": count,
|
||||
"doc_count": docCount,
|
||||
"web_count": webCount,
|
||||
"search_source": searchSource,
|
||||
"candidate_count": candidateCount,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func hasKBRetrievalTargets(chatManage *types.ChatManage) bool {
|
||||
if chatManage == nil {
|
||||
return false
|
||||
}
|
||||
return types.HasKnowledgeRetrievalScope(
|
||||
chatManage.SearchTargets,
|
||||
chatManage.KnowledgeBaseIDs,
|
||||
chatManage.KnowledgeIDs,
|
||||
)
|
||||
}
|
||||
|
||||
func retrievalSearchSource(chatManage *types.ChatManage) string {
|
||||
hasKB := hasKBRetrievalTargets(chatManage)
|
||||
hasWeb := chatManage != nil && chatManage.WebSearchEnabled
|
||||
switch {
|
||||
case hasKB && hasWeb:
|
||||
return retrievalSourceMixed
|
||||
case hasWeb:
|
||||
return retrievalSourceWeb
|
||||
default:
|
||||
return retrievalSourceKnowledge
|
||||
}
|
||||
}
|
||||
|
||||
func retrievalResults(chatManage *types.ChatManage) []*types.SearchResult {
|
||||
switch {
|
||||
case len(chatManage.MergeResult) > 0:
|
||||
return chatManage.MergeResult
|
||||
case len(chatManage.RerankResult) > 0:
|
||||
return chatManage.RerankResult
|
||||
default:
|
||||
return chatManage.SearchResult
|
||||
}
|
||||
}
|
||||
|
||||
func retrievalResultBreakdown(chatManage *types.ChatManage) (total, docCount, webCount int) {
|
||||
for _, result := range retrievalResults(chatManage) {
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if isWebSearchResult(result) {
|
||||
webCount++
|
||||
} else {
|
||||
docCount++
|
||||
}
|
||||
}
|
||||
return total, docCount, webCount
|
||||
}
|
||||
|
||||
func isWebSearchResult(result *types.SearchResult) bool {
|
||||
if strings.EqualFold(result.ChunkType, "web_search") {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(result.KnowledgeSource, "web_search")
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type recordingEventBus struct {
|
||||
events []types.Event
|
||||
}
|
||||
|
||||
func (b *recordingEventBus) On(types.EventType, types.EventHandler) {}
|
||||
|
||||
func (b *recordingEventBus) Emit(_ context.Context, evt types.Event) error {
|
||||
b.events = append(b.events, evt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestIsConsolidatedRetrievalStage(t *testing.T) {
|
||||
cm := &types.ChatManage{}
|
||||
assert.True(t, IsConsolidatedRetrievalStage(types.CHUNK_SEARCH_PARALLEL, cm))
|
||||
assert.False(t, IsConsolidatedRetrievalStage(types.QUERY_UNDERSTAND, cm))
|
||||
assert.False(t, IsConsolidatedRetrievalStage(types.LOAD_HISTORY, cm))
|
||||
}
|
||||
|
||||
func TestLastConsolidatedRetrievalStage(t *testing.T) {
|
||||
cm := &types.ChatManage{}
|
||||
pipeline := []types.EventType{
|
||||
types.LOAD_HISTORY,
|
||||
types.QUERY_UNDERSTAND,
|
||||
types.CHUNK_SEARCH_PARALLEL,
|
||||
types.CHUNK_RERANK,
|
||||
types.CHUNK_MERGE,
|
||||
types.FILTER_TOP_K,
|
||||
types.INTO_CHAT_MESSAGE,
|
||||
types.CHAT_COMPLETION_STREAM,
|
||||
}
|
||||
assert.Equal(t, types.FILTER_TOP_K, LastConsolidatedRetrievalStage(pipeline, cm))
|
||||
}
|
||||
|
||||
func TestShouldCloseRetrievalProgress(t *testing.T) {
|
||||
last := types.FILTER_TOP_K
|
||||
|
||||
// Normal completion: only the last retrieval stage closes the window.
|
||||
assert.True(t, ShouldCloseRetrievalProgress(types.FILTER_TOP_K, last, nil))
|
||||
assert.False(t, ShouldCloseRetrievalProgress(types.CHUNK_SEARCH_PARALLEL, last, nil))
|
||||
|
||||
// ErrSearchNothing at an earlier retrieval stage must still close the
|
||||
// window so the frontend stops spinning before the fallback answer streams.
|
||||
assert.True(t, ShouldCloseRetrievalProgress(types.CHUNK_SEARCH_PARALLEL, last, ErrSearchNothing))
|
||||
|
||||
// A hard error at any retrieval stage must also close the window.
|
||||
assert.True(t, ShouldCloseRetrievalProgress(types.CHUNK_RERANK, last, &PluginError{}))
|
||||
}
|
||||
|
||||
func TestShouldEmitQueryUnderstandProgress(t *testing.T) {
|
||||
cm := &types.ChatManage{PipelineRequest: types.PipelineRequest{EnableRewrite: true}}
|
||||
assert.True(t, ShouldEmitQueryUnderstandProgress(cm))
|
||||
|
||||
cm.EnableRewrite = false
|
||||
assert.False(t, ShouldEmitQueryUnderstandProgress(cm))
|
||||
|
||||
cm.Images = []string{"data:image/png;base64,abc"}
|
||||
assert.True(t, ShouldEmitQueryUnderstandProgress(cm))
|
||||
}
|
||||
|
||||
func TestQueryUnderstandProgressEmitsToolCallAndResult(t *testing.T) {
|
||||
bus := &recordingEventBus{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{SessionID: "sess-1", EnableRewrite: true},
|
||||
PipelineContext: types.PipelineContext{EventBus: bus},
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
progress := BeginQueryUnderstandProgress(context.Background(), cm)
|
||||
require.NotNil(t, progress)
|
||||
EndQueryUnderstandProgress(context.Background(), cm, progress, start, nil)
|
||||
|
||||
require.Len(t, bus.events, 2)
|
||||
callData, ok := bus.events[0].Data.(event.AgentToolCallData)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "query_understand", callData.ToolName)
|
||||
}
|
||||
|
||||
func TestRetrievalProgressEmitsSingleToolCallAndResult(t *testing.T) {
|
||||
bus := &recordingEventBus{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{SessionID: "sess-1"},
|
||||
PipelineContext: types.PipelineContext{EventBus: bus},
|
||||
PipelineState: types.PipelineState{
|
||||
MergeResult: []*types.SearchResult{{ID: "r1"}, {ID: "r2"}, {ID: "r3"}},
|
||||
},
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
progress := BeginRetrievalProgress(context.Background(), cm)
|
||||
require.NotNil(t, progress)
|
||||
EndRetrievalProgress(context.Background(), cm, progress, start, nil)
|
||||
|
||||
require.Len(t, bus.events, 2)
|
||||
assert.Equal(t, types.EventType(event.EventAgentToolCall), bus.events[0].Type)
|
||||
assert.Equal(t, types.EventType(event.EventAgentToolResult), bus.events[1].Type)
|
||||
|
||||
callData, ok := bus.events[0].Data.(event.AgentToolCallData)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "knowledge_search", callData.ToolName)
|
||||
assert.Equal(t, retrievalSourceKnowledge, callData.Arguments["search_source"])
|
||||
|
||||
resultData, ok := bus.events[1].Data.(event.AgentToolResultData)
|
||||
require.True(t, ok)
|
||||
assert.True(t, resultData.Success)
|
||||
assert.Equal(t, 3, resultData.Data["count"])
|
||||
assert.Equal(t, 3, resultData.Data["doc_count"])
|
||||
assert.Equal(t, 0, resultData.Data["web_count"])
|
||||
assert.Equal(t, retrievalSourceKnowledge, resultData.Data["search_source"])
|
||||
}
|
||||
|
||||
// A turn whose candidates all fell below the relevance threshold answers from
|
||||
// the fallback, which never sees a retrieved chunk. Reporting the raw hits as
|
||||
// the result count claimed context the answer did not have — and offered a row
|
||||
// with no references behind it.
|
||||
func TestRetrievalProgressReportsNoResultsWhenPipelineFellBack(t *testing.T) {
|
||||
bus := &recordingEventBus{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{SessionID: "sess-fallback"},
|
||||
PipelineContext: types.PipelineContext{EventBus: bus},
|
||||
PipelineState: types.PipelineState{
|
||||
SearchResult: []*types.SearchResult{{ID: "r1"}, {ID: "r2"}, {ID: "r3"}},
|
||||
},
|
||||
}
|
||||
|
||||
progress := BeginRetrievalProgress(context.Background(), cm)
|
||||
require.NotNil(t, progress)
|
||||
EndRetrievalProgress(context.Background(), cm, progress, time.Now(), ErrSearchNothing)
|
||||
|
||||
resultData, ok := bus.events[1].Data.(event.AgentToolResultData)
|
||||
require.True(t, ok)
|
||||
assert.True(t, resultData.Success)
|
||||
assert.Equal(t, 0, resultData.Data["count"])
|
||||
assert.Equal(t, 0, resultData.Data["doc_count"])
|
||||
assert.Equal(t, 0, resultData.Data["web_count"])
|
||||
assert.Equal(t, 3, resultData.Data["candidate_count"])
|
||||
assert.Equal(t, "命中 3 条候选,相关性不足,未用于回答", resultData.Output)
|
||||
}
|
||||
|
||||
func TestRetrievalProgressReportsNothingFoundWhenThereWereNoCandidates(t *testing.T) {
|
||||
bus := &recordingEventBus{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{SessionID: "sess-empty"},
|
||||
PipelineContext: types.PipelineContext{EventBus: bus},
|
||||
}
|
||||
|
||||
progress := BeginRetrievalProgress(context.Background(), cm)
|
||||
require.NotNil(t, progress)
|
||||
EndRetrievalProgress(context.Background(), cm, progress, time.Now(), ErrSearchNothing)
|
||||
|
||||
resultData, ok := bus.events[1].Data.(event.AgentToolResultData)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 0, resultData.Data["count"])
|
||||
assert.Equal(t, 0, resultData.Data["candidate_count"])
|
||||
assert.Equal(t, "未检索到相关内容", resultData.Output)
|
||||
}
|
||||
|
||||
func TestRetrievalProgressWebOnlySearchSource(t *testing.T) {
|
||||
bus := &recordingEventBus{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SessionID: "sess-web",
|
||||
WebSearchEnabled: true,
|
||||
},
|
||||
PipelineContext: types.PipelineContext{EventBus: bus},
|
||||
PipelineState: types.PipelineState{
|
||||
MergeResult: []*types.SearchResult{
|
||||
{ID: "w1", ChunkType: "web_search"},
|
||||
{ID: "w2", KnowledgeSource: "web_search"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
progress := BeginRetrievalProgress(context.Background(), cm)
|
||||
require.NotNil(t, progress)
|
||||
EndRetrievalProgress(context.Background(), cm, progress, start, nil)
|
||||
|
||||
callData, ok := bus.events[0].Data.(event.AgentToolCallData)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, retrievalSourceWeb, callData.Arguments["search_source"])
|
||||
|
||||
resultData, ok := bus.events[1].Data.(event.AgentToolResultData)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 2, resultData.Data["count"])
|
||||
assert.Equal(t, 0, resultData.Data["doc_count"])
|
||||
assert.Equal(t, 2, resultData.Data["web_count"])
|
||||
assert.Equal(t, retrievalSourceWeb, resultData.Data["search_source"])
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// runQueryExpansion performs query expansion when initial recall is low.
|
||||
// It generates query variants and runs concurrent retrieval across search targets.
|
||||
func (p *PluginSearch) runQueryExpansion(ctx context.Context, chatManage *types.ChatManage) []*types.SearchResult {
|
||||
pipelineInfo(ctx, "Search", "recall_low", map[string]interface{}{
|
||||
"current": len(chatManage.SearchResult),
|
||||
"threshold": chatManage.EmbeddingTopK,
|
||||
})
|
||||
expansions := p.expandQueries(ctx, chatManage)
|
||||
if len(expansions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Search", "expansion_start", map[string]interface{}{
|
||||
"variants": len(expansions),
|
||||
})
|
||||
expTopK := max(chatManage.EmbeddingTopK*2, chatManage.RerankTopK*2)
|
||||
expKwTh := chatManage.KeywordThreshold * 0.8
|
||||
|
||||
// Concurrent expansion retrieval across queries and search targets
|
||||
expResults := make([]*types.SearchResult, 0, expTopK*len(expansions))
|
||||
var muExp sync.Mutex
|
||||
var wgExp sync.WaitGroup
|
||||
jobs := len(expansions) * len(chatManage.SearchTargets)
|
||||
capSem := 16
|
||||
if jobs < capSem {
|
||||
capSem = jobs
|
||||
}
|
||||
if capSem <= 0 {
|
||||
capSem = 1
|
||||
}
|
||||
sem := make(chan struct{}, capSem)
|
||||
pipelineInfo(ctx, "Search", "expansion_concurrency", map[string]interface{}{
|
||||
"jobs": jobs,
|
||||
"cap": capSem,
|
||||
})
|
||||
for _, q := range expansions {
|
||||
for _, target := range chatManage.SearchTargets {
|
||||
if target == nil || target.KnowledgeBaseID == "" {
|
||||
continue
|
||||
}
|
||||
wgExp.Add(1)
|
||||
go func(q string, t *types.SearchTarget) {
|
||||
defer wgExp.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
vectorThreshold, keywordThreshold := t.RecallThresholds(
|
||||
chatManage.VectorThreshold,
|
||||
expKwTh,
|
||||
)
|
||||
paramsExp := types.SearchParams{
|
||||
QueryText: q,
|
||||
VectorThreshold: vectorThreshold,
|
||||
KeywordThreshold: keywordThreshold,
|
||||
MatchCount: expTopK,
|
||||
TagIDs: t.TagIDs,
|
||||
ScopeTagIDs: t.ScopeTagIDs,
|
||||
DisableVectorMatch: false,
|
||||
DisableKeywordsMatch: false,
|
||||
SkipContextEnrichment: true, // Pipeline handles context assembly in merge stage
|
||||
}
|
||||
// Apply knowledge ID filter if this is a partial KB search
|
||||
if t.Type == types.SearchTargetTypeKnowledge {
|
||||
paramsExp.KnowledgeIDs = t.KnowledgeIDs
|
||||
}
|
||||
res, err := p.knowledgeBaseService.HybridSearch(ctx, t.KnowledgeBaseID, paramsExp)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Search", "expansion_error", map[string]interface{}{
|
||||
"kb_id": t.KnowledgeBaseID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(res) > 0 {
|
||||
for _, r := range res {
|
||||
r.KnowledgeBaseID = t.KnowledgeBaseID
|
||||
}
|
||||
pipelineInfo(ctx, "Search", "expansion_hits", map[string]interface{}{
|
||||
"kb_id": t.KnowledgeBaseID,
|
||||
"query": q,
|
||||
"hits": len(res),
|
||||
})
|
||||
muExp.Lock()
|
||||
expResults = append(expResults, res...)
|
||||
muExp.Unlock()
|
||||
}
|
||||
}(q, target)
|
||||
}
|
||||
}
|
||||
wgExp.Wait()
|
||||
|
||||
if len(expResults) > 0 {
|
||||
pipelineInfo(ctx, "Search", "expansion_done", map[string]interface{}{
|
||||
"added": len(expResults),
|
||||
})
|
||||
}
|
||||
return expResults
|
||||
}
|
||||
|
||||
// expandQueries generates query variants locally without LLM to improve keyword recall.
|
||||
// Uses simple techniques: word reordering, stopword removal, key phrase extraction.
|
||||
func (p *PluginSearch) expandQueries(ctx context.Context, chatManage *types.ChatManage) []string {
|
||||
query := strings.TrimSpace(chatManage.RewriteQuery)
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
expansions := make([]string, 0, 5)
|
||||
seen := make(map[string]struct{})
|
||||
seen[strings.ToLower(query)] = struct{}{}
|
||||
if q := strings.ToLower(chatManage.Query); q != "" {
|
||||
seen[q] = struct{}{}
|
||||
}
|
||||
|
||||
addIfNew := func(s string) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || len(s) < 3 {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(s)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
expansions = append(expansions, s)
|
||||
}
|
||||
|
||||
// 1. Remove common stopwords and create keyword-only variant
|
||||
keywords := extractKeywords(query)
|
||||
if len(keywords) >= 2 {
|
||||
addIfNew(strings.Join(keywords, " "))
|
||||
}
|
||||
|
||||
// 2. Extract quoted phrases or key segments
|
||||
phrases := extractPhrases(query)
|
||||
for _, phrase := range phrases {
|
||||
addIfNew(phrase)
|
||||
}
|
||||
|
||||
// 3. Split by common delimiters and use longest segment
|
||||
segments := splitByDelimiters(query)
|
||||
for _, seg := range segments {
|
||||
if len(seg) > 5 {
|
||||
addIfNew(seg)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Remove question words (什么/如何/怎么/为什么/哪个 etc.)
|
||||
cleaned := removeQuestionWords(query)
|
||||
if cleaned != query {
|
||||
addIfNew(cleaned)
|
||||
}
|
||||
|
||||
// Limit to 5 expansions
|
||||
if len(expansions) > 5 {
|
||||
expansions = expansions[:5]
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Search", "local_expansion_result", map[string]interface{}{
|
||||
"variants": len(expansions),
|
||||
})
|
||||
return expansions
|
||||
}
|
||||
|
||||
// Common Chinese and English stopwords
|
||||
var stopwords = map[string]struct{}{
|
||||
"的": {}, "是": {}, "在": {}, "了": {}, "和": {}, "与": {}, "或": {},
|
||||
"a": {}, "an": {}, "the": {}, "is": {}, "are": {}, "was": {}, "were": {},
|
||||
"be": {}, "been": {}, "being": {}, "have": {}, "has": {}, "had": {},
|
||||
"do": {}, "does": {}, "did": {}, "will": {}, "would": {}, "could": {},
|
||||
"should": {}, "may": {}, "might": {}, "must": {}, "can": {},
|
||||
"to": {}, "of": {}, "in": {}, "for": {}, "on": {}, "with": {}, "at": {},
|
||||
"by": {}, "from": {}, "as": {}, "into": {}, "through": {}, "about": {},
|
||||
"what": {}, "how": {}, "why": {}, "when": {}, "where": {}, "which": {},
|
||||
"who": {}, "whom": {}, "whose": {},
|
||||
}
|
||||
|
||||
// Question words in Chinese
|
||||
var questionWords = regexp.MustCompile(`^(什么是|什么|如何|怎么|怎样|为什么|为何|哪个|哪些|谁|何时|何地|请问|请告诉我|帮我|我想知道|我想了解)`)
|
||||
|
||||
func extractKeywords(text string) []string {
|
||||
words := tokenize(text)
|
||||
keywords := make([]string, 0, len(words))
|
||||
for _, w := range words {
|
||||
lower := strings.ToLower(w)
|
||||
if _, isStop := stopwords[lower]; !isStop && utf8.RuneCountInString(w) > 1 {
|
||||
keywords = append(keywords, w)
|
||||
}
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
|
||||
func extractPhrases(text string) []string {
|
||||
// Extract quoted content
|
||||
var phrases []string
|
||||
re := regexp.MustCompile(`["'"'「」『』]([^"'"'「」『』]+)["'"'「」『』]`)
|
||||
matches := re.FindAllStringSubmatch(text, -1)
|
||||
for _, m := range matches {
|
||||
if len(m) > 1 && len(m[1]) > 2 {
|
||||
phrases = append(phrases, m[1])
|
||||
}
|
||||
}
|
||||
return phrases
|
||||
}
|
||||
|
||||
func splitByDelimiters(text string) []string {
|
||||
// Split by common delimiters
|
||||
re := regexp.MustCompile(`[,,;;、。!?!?\s]+`)
|
||||
parts := re.Split(text, -1)
|
||||
var result []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func removeQuestionWords(text string) string {
|
||||
return strings.TrimSpace(questionWords.ReplaceAllString(text, ""))
|
||||
}
|
||||
|
||||
func tokenize(text string) []string {
|
||||
var tokens []string
|
||||
var current strings.Builder
|
||||
currentIsHan := false
|
||||
|
||||
flush := func() {
|
||||
if current.Len() == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if currentIsHan {
|
||||
// Use the existing search-mode dictionary for continuous Chinese text.
|
||||
for _, word := range types.Jieba.CutForSearch(current.String(), true) {
|
||||
word = strings.TrimSpace(word)
|
||||
if word != "" {
|
||||
tokens = append(tokens, word)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tokens = append(tokens, current.String())
|
||||
}
|
||||
|
||||
current.Reset()
|
||||
currentIsHan = false
|
||||
}
|
||||
|
||||
for _, r := range text {
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
if current.Len() > 0 && !currentIsHan {
|
||||
flush()
|
||||
}
|
||||
currentIsHan = true
|
||||
current.WriteRune(r)
|
||||
} else if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
if current.Len() > 0 && currentIsHan {
|
||||
flush()
|
||||
}
|
||||
currentIsHan = false
|
||||
current.WriteRune(r)
|
||||
} else {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return tokens
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestExtractKeywordsSegmentsChineseQuery(t *testing.T) {
|
||||
keywords := extractKeywords("如何配置向量数据库")
|
||||
|
||||
assert.NotContains(t, keywords, "如何配置向量数据库")
|
||||
assert.Contains(t, keywords, "配置")
|
||||
assert.Contains(t, keywords, "向量")
|
||||
assert.Contains(t, keywords, "数据库")
|
||||
}
|
||||
|
||||
func TestTokenizePreservesMixedLanguageBoundaries(t *testing.T) {
|
||||
tokens := tokenize("RAG如何配置PostgreSQL")
|
||||
|
||||
assert.Contains(t, tokens, "RAG")
|
||||
assert.Contains(t, tokens, "配置")
|
||||
assert.Contains(t, tokens, "PostgreSQL")
|
||||
assert.NotContains(t, tokens, "RAG如何配置")
|
||||
}
|
||||
|
||||
func TestExtractKeywordsDropsSingleRuneChineseTokens(t *testing.T) {
|
||||
keywords := extractKeywords("他来到了网易杭研大厦")
|
||||
|
||||
assert.Contains(t, keywords, "网易")
|
||||
assert.Contains(t, keywords, "大厦")
|
||||
for _, keyword := range keywords {
|
||||
assert.Greater(t, utf8.RuneCountInString(keyword), 1, "unexpected single-rune keyword %q", keyword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandQueriesBuildsChineseKeywordVariant(t *testing.T) {
|
||||
expansions := (&PluginSearch{}).expandQueries(context.Background(), &types.ChatManage{
|
||||
PipelineState: types.PipelineState{RewriteQuery: "如何配置向量数据库"},
|
||||
})
|
||||
|
||||
var foundKeywordVariant bool
|
||||
for _, expansion := range expansions {
|
||||
fields := strings.Fields(expansion)
|
||||
if containsToken(fields, "配置") && containsToken(fields, "向量") && containsToken(fields, "数据库") {
|
||||
foundKeywordVariant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, foundKeywordVariant, "expected a Chinese keyword expansion with segmented terms, got %v", expansions)
|
||||
}
|
||||
|
||||
func containsToken(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginQueryUnderstand performs query rewriting and intent classification.
|
||||
// It uses conversation history and an LLM to optimise the user's original query
|
||||
// and determine the downstream pipeline behaviour.
|
||||
type PluginQueryUnderstand struct {
|
||||
modelService interfaces.ModelService
|
||||
messageService interfaces.MessageService
|
||||
memoryService interfaces.MemoryService
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
var rewriteImageSepPattern = regexp.MustCompile(`(?s)^(.*?)\s*\n?---\n(.*)$`)
|
||||
|
||||
type queryUnderstandOutput struct {
|
||||
RewriteQuery string `json:"rewrite_query"`
|
||||
Intent types.QueryIntent `json:"intent"`
|
||||
ImageDescription string `json:"image_description"`
|
||||
}
|
||||
|
||||
// NewPluginQueryUnderstand creates a new query-understanding plugin instance
|
||||
// and registers it with the event manager.
|
||||
func NewPluginQueryUnderstand(eventManager *EventManager,
|
||||
modelService interfaces.ModelService, messageService interfaces.MessageService,
|
||||
memoryService interfaces.MemoryService,
|
||||
config *config.Config,
|
||||
) *PluginQueryUnderstand {
|
||||
res := &PluginQueryUnderstand{
|
||||
modelService: modelService,
|
||||
messageService: messageService,
|
||||
memoryService: memoryService,
|
||||
config: config,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the list of event types this plugin responds to.
|
||||
func (p *PluginQueryUnderstand) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.QUERY_UNDERSTAND}
|
||||
}
|
||||
|
||||
// OnEvent processes triggered events.
|
||||
// Handles three input combinations:
|
||||
// - Text only: standard rewrite + intent classification (uses chat model)
|
||||
// - Text + images: multimodal rewrite + intent + image description (uses VLM/vision model)
|
||||
// - Images only: multimodal analysis + intent + image description (uses VLM/vision model)
|
||||
func (p *PluginQueryUnderstand) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
chatManage.RewriteQuery = chatManage.Query
|
||||
|
||||
hasImages := len(chatManage.Images) > 0
|
||||
needRewrite := chatManage.EnableRewrite
|
||||
if !needRewrite && !hasImages {
|
||||
pipelineInfo(ctx, "QueryUnderstand", "skip", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"reason": "rewrite_disabled_no_images",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "QueryUnderstand", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"tenant_id": chatManage.TenantID,
|
||||
"user_query": chatManage.Query,
|
||||
"has_images": hasImages,
|
||||
"enable_rewrite": chatManage.EnableRewrite,
|
||||
})
|
||||
|
||||
// --- Load and prepare conversation history ---
|
||||
var historyList []*types.History
|
||||
if len(chatManage.History) > 0 {
|
||||
historyList = chatManage.History
|
||||
pipelineInfo(ctx, "QueryUnderstand", "history_reused", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"rounds": len(historyList),
|
||||
})
|
||||
} else {
|
||||
historyList = p.loadHistory(ctx, chatManage)
|
||||
}
|
||||
|
||||
// --- Select the appropriate model ---
|
||||
rewriteModel, useImages := p.selectModel(ctx, chatManage, hasImages)
|
||||
if rewriteModel == nil {
|
||||
pipelineError(ctx, "QueryUnderstand", "get_model", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
// --- Build prompts ---
|
||||
systemContent, userContent := p.buildPrompts(ctx, chatManage, historyList)
|
||||
|
||||
userMsg := chat.Message{Role: "user", Content: userContent}
|
||||
if useImages {
|
||||
userMsg.Images = chatManage.Images
|
||||
}
|
||||
|
||||
maxTokens := 150
|
||||
if useImages {
|
||||
maxTokens = 500
|
||||
}
|
||||
|
||||
// --- Call model ---
|
||||
thinking := false
|
||||
modelCtx := types.WithLLMCallMetadata(ctx, "query_rewrite", "")
|
||||
response, err := rewriteModel.Chat(modelCtx, []chat.Message{
|
||||
{Role: "system", Content: systemContent},
|
||||
userMsg,
|
||||
}, &chat.ChatOptions{
|
||||
Temperature: 0.3,
|
||||
MaxCompletionTokens: maxTokens,
|
||||
Thinking: &thinking,
|
||||
})
|
||||
if err != nil {
|
||||
pipelineError(ctx, "QueryUnderstand", "model_call", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
// --- Parse structured output ---
|
||||
p.parseOutput(chatManage, response.Content)
|
||||
|
||||
// Persist image description asynchronously — this DB write does not affect
|
||||
// the current pipeline result, so it can run in the background.
|
||||
if chatManage.ImageDescription != "" && chatManage.UserMessageID != "" {
|
||||
go p.updateUserMessageImageCaption(context.WithoutCancel(ctx), chatManage)
|
||||
}
|
||||
|
||||
// --- Apply intent-specific system prompt override ---
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
if applyIntentPromptOverride(chatManage, p.config.Conversation.IntentSystemPrompts) {
|
||||
pipelineInfo(ctx, "QueryUnderstand", "prompt_override", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"intent": chatManage.Intent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "QueryUnderstand", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"rewrite_query": chatManage.RewriteQuery,
|
||||
"intent": chatManage.Intent,
|
||||
"has_image_desc": chatManage.ImageDescription != "",
|
||||
"has_prompt_override": chatManage.SystemPromptOverride != "",
|
||||
"original_output": response.Content,
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
// updateUserMessageImageCaption writes the generated ImageDescription back to
|
||||
// the stored user message so that subsequent turns can see it in history.
|
||||
func (p *PluginQueryUnderstand) updateUserMessageImageCaption(ctx context.Context, chatManage *types.ChatManage) {
|
||||
msg, err := p.messageService.GetMessage(ctx, chatManage.SessionID, chatManage.UserMessageID)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "QueryUnderstand", "get_user_message", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_message_id": chatManage.UserMessageID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(msg.Images) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
msg.Images[0].Caption = chatManage.ImageDescription
|
||||
|
||||
if err := p.messageService.UpdateMessageImages(ctx, chatManage.SessionID, chatManage.UserMessageID, msg.Images); err != nil {
|
||||
pipelineWarn(ctx, "QueryUnderstand", "update_image_caption", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_message_id": chatManage.UserMessageID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// loadHistory fetches and processes conversation history for rewrite context.
|
||||
func (p *PluginQueryUnderstand) loadHistory(ctx context.Context, chatManage *types.ChatManage) []*types.History {
|
||||
// Honor the multi-turn-disabled signal: chatManage.MaxRounds == 0 is set
|
||||
// explicitly by applyAgentOverridesToChatManage when the custom agent has
|
||||
// MultiTurnEnabled=false. We must not silently fall back to the global
|
||||
// default, otherwise rewrite + image analysis would still pull old turns
|
||||
// into the context and leak through chatManage.History.
|
||||
if chatManage.MaxRounds <= 0 {
|
||||
return nil
|
||||
}
|
||||
maxRounds := chatManage.MaxRounds
|
||||
|
||||
historyList, err := loadAndProcessHistory(ctx, p.messageService, chatManage.SessionID, maxRounds, 20)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "QueryUnderstand", "history_fetch", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
chatManage.History = historyList
|
||||
|
||||
if len(historyList) > 0 {
|
||||
pipelineInfo(ctx, "QueryUnderstand", "history_ready", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"history_rounds": len(historyList),
|
||||
})
|
||||
}
|
||||
|
||||
return historyList
|
||||
}
|
||||
|
||||
// selectModel picks the model for query understanding. When images are present
|
||||
// it prefers a vision-capable model. Returns (model, useImages).
|
||||
func (p *PluginQueryUnderstand) selectModel(ctx context.Context, chatManage *types.ChatManage, hasImages bool) (chat.Chat, bool) {
|
||||
if hasImages {
|
||||
if chatManage.ChatModelSupportsVision {
|
||||
m, err := p.modelService.GetChatModel(ctx, chatManage.ChatModelID)
|
||||
if err == nil {
|
||||
return m, true
|
||||
}
|
||||
pipelineWarn(ctx, "QueryUnderstand", "vision_model_fallback", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
if chatManage.VLMModelID != "" {
|
||||
m, err := p.modelService.GetChatModel(ctx, chatManage.VLMModelID)
|
||||
if err == nil {
|
||||
return m, true
|
||||
}
|
||||
pipelineWarn(ctx, "QueryUnderstand", "vlm_model_fallback", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"vlm_model_id": chatManage.VLMModelID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
pipelineWarn(ctx, "QueryUnderstand", "no_vision_model", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
}
|
||||
|
||||
textModelID := chatManage.ChatModelID
|
||||
if chatManage.QueryUnderstandModelID != "" {
|
||||
textModelID = chatManage.QueryUnderstandModelID
|
||||
}
|
||||
m, err := p.modelService.GetChatModel(ctx, textModelID)
|
||||
if err != nil {
|
||||
// Fall back to ChatModelID when a dedicated query-understand model was
|
||||
// configured but cannot be resolved (e.g. deleted / disabled).
|
||||
if chatManage.QueryUnderstandModelID != "" && textModelID != chatManage.ChatModelID {
|
||||
pipelineWarn(ctx, "QueryUnderstand", "query_understand_model_fallback", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"query_understand_model_id": chatManage.QueryUnderstandModelID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
if fallback, fbErr := p.modelService.GetChatModel(ctx, chatManage.ChatModelID); fbErr == nil {
|
||||
return fallback, false
|
||||
}
|
||||
}
|
||||
pipelineError(ctx, "QueryUnderstand", "get_model", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"chat_model_id": textModelID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
return m, false
|
||||
}
|
||||
|
||||
// buildPrompts constructs system and user prompts with placeholder replacement.
|
||||
func (p *PluginQueryUnderstand) buildPrompts(
|
||||
ctx context.Context, chatManage *types.ChatManage, historyList []*types.History,
|
||||
) (string, string) {
|
||||
userPrompt := p.config.Conversation.RewritePromptUser
|
||||
if chatManage.RewritePromptUser != "" {
|
||||
userPrompt = chatManage.RewritePromptUser
|
||||
}
|
||||
systemPrompt := p.config.Conversation.RewritePromptSystem
|
||||
if chatManage.RewritePromptSystem != "" {
|
||||
systemPrompt = chatManage.RewritePromptSystem
|
||||
}
|
||||
|
||||
conversationText := formatConversationHistory(historyList)
|
||||
|
||||
queryContent := chatManage.Query
|
||||
if len(chatManage.Images) > 0 {
|
||||
queryContent += fmt.Sprintf("\n\n<images_uploaded count=\"%d\" />", len(chatManage.Images))
|
||||
} else {
|
||||
queryContent += "\n\n<no_image_attached />"
|
||||
}
|
||||
if len(chatManage.Attachments) > 0 {
|
||||
queryContent += chatManage.Attachments.BuildPrompt()
|
||||
} else {
|
||||
queryContent += "\n<no_document_attached />"
|
||||
}
|
||||
queryContent += p.memoryBackground(ctx, chatManage)
|
||||
|
||||
vals := types.PlaceholderValues{
|
||||
"conversation": conversationText,
|
||||
"query": queryContent,
|
||||
"language": chatManage.Language,
|
||||
}
|
||||
|
||||
return types.RenderPromptPlaceholders(systemPrompt, vals),
|
||||
types.RenderPromptPlaceholders(userPrompt, vals)
|
||||
}
|
||||
|
||||
// memoryBackground gives the rewriter who is asking.
|
||||
//
|
||||
// This is the point where long-term memory stops being a paragraph appended to
|
||||
// the answer prompt and starts changing what gets retrieved. "How do I tune the
|
||||
// segmentation" is a different search for someone who works on medical imaging
|
||||
// than for someone who works on autonomous driving, and the only place that
|
||||
// difference can be applied is before retrieval runs.
|
||||
//
|
||||
// It is deliberately advisory rather than a filter. Memory narrows nothing and
|
||||
// excludes no knowledge base: a stale note about last quarter's project must
|
||||
// not be able to make this quarter's documents unreachable.
|
||||
func (p *PluginQueryUnderstand) memoryBackground(ctx context.Context, chatManage *types.ChatManage) string {
|
||||
if p.memoryService == nil {
|
||||
return ""
|
||||
}
|
||||
memCtx := p.memoryService.RetrievalContextFor(ctx)
|
||||
if memCtx.Empty() {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n<asker_background note=\"背景仅用于消解指代和补全检索词,不要当作问题的一部分\">")
|
||||
if memCtx.Background != "" {
|
||||
b.WriteString("\n" + memCtx.Background)
|
||||
}
|
||||
if len(memCtx.Interests) > 0 {
|
||||
b.WriteString("\n长期关注:" + strings.Join(memCtx.Interests, "、"))
|
||||
}
|
||||
if len(memCtx.Documents) > 0 {
|
||||
b.WriteString("\n常查资料:" + strings.Join(memCtx.Documents, "、"))
|
||||
}
|
||||
b.WriteString("\n</asker_background>")
|
||||
|
||||
// Deliberately does not add to chatManage.UsedMemories. What this reads is
|
||||
// the whole standing background, unfiltered — that is the right input for a
|
||||
// rewriter, but reporting it would claim every turn recalled memories that
|
||||
// have nothing to do with the question. Which memories this turn actually
|
||||
// used is decided in MEMORY_RECALL, by relevance, and the profile entries
|
||||
// here are already reported from there.
|
||||
fields := map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"interests": len(memCtx.Interests),
|
||||
"documents": len(memCtx.Documents),
|
||||
"items": len(memCtx.Items),
|
||||
}
|
||||
if len(memCtx.Interests) > 0 {
|
||||
fields["interest_previews"] = memCtx.Interests
|
||||
}
|
||||
pipelineInfo(ctx, "QueryUnderstand", "memory_background", fields)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseOutput extracts the rewritten query, intent classification, and optional
|
||||
// image description from the model's structured JSON output.
|
||||
//
|
||||
// Expected format: {"rewrite_query":"...","intent":"kb_search","image_description":"..."}
|
||||
func (p *PluginQueryUnderstand) parseOutput(chatManage *types.ChatManage, raw string) {
|
||||
content := strings.TrimSpace(raw)
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if output, ok := parseStructuredQueryOutput(content); ok {
|
||||
if rewrite := strings.TrimSpace(output.RewriteQuery); rewrite != "" {
|
||||
chatManage.RewriteQuery = rewrite
|
||||
}
|
||||
chatManage.Intent = output.Intent
|
||||
chatManage.ImageDescription = strings.TrimSpace(output.ImageDescription)
|
||||
return
|
||||
}
|
||||
|
||||
// On parse failure, keep the original query and intent.
|
||||
}
|
||||
|
||||
func parseStructuredQueryOutput(raw string) (queryUnderstandOutput, bool) {
|
||||
content := strings.TrimSpace(raw)
|
||||
if content == "" {
|
||||
return queryUnderstandOutput{}, false
|
||||
}
|
||||
|
||||
if parsed, ok := parseStructuredQueryOutputJSON(content); ok {
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// Be tolerant to occasional markdown wrappers or extra prose.
|
||||
start := strings.Index(content, "{")
|
||||
end := strings.LastIndex(content, "}")
|
||||
if start < 0 || end <= start {
|
||||
return queryUnderstandOutput{}, false
|
||||
}
|
||||
candidate := content[start : end+1]
|
||||
if parsed, ok := parseStructuredQueryOutputJSON(candidate); ok {
|
||||
return parsed, true
|
||||
}
|
||||
return queryUnderstandOutput{}, false
|
||||
}
|
||||
|
||||
func parseStructuredQueryOutputJSON(content string) (queryUnderstandOutput, bool) {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(content), &obj); err != nil {
|
||||
return queryUnderstandOutput{}, false
|
||||
}
|
||||
|
||||
out := queryUnderstandOutput{
|
||||
RewriteQuery: strings.TrimSpace(firstStringField(obj,
|
||||
"rewrite_query", "rewritten_query", "query", "question")),
|
||||
}
|
||||
|
||||
intentStr := strings.TrimSpace(firstStringField(obj, "intent"))
|
||||
if intentStr != "" {
|
||||
out.Intent = types.QueryIntent(intentStr)
|
||||
}
|
||||
|
||||
desc := strings.TrimSpace(firstStringField(obj,
|
||||
"image_description", "image_desc", "image_text", "image_ocr_text", "description"))
|
||||
ocr := strings.TrimSpace(firstStringField(obj,
|
||||
"ocr_text", "ocr", "full_ocr", "image_ocr", "ocr_content"))
|
||||
combined, set := mergeImageDescAndOCR(desc, ocr)
|
||||
if set {
|
||||
out.ImageDescription = combined
|
||||
}
|
||||
|
||||
return out, true
|
||||
}
|
||||
|
||||
func firstStringField(obj map[string]json.RawMessage, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
raw, ok := obj[key]
|
||||
if !ok || len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mergeImageDescAndOCR(desc, ocr string) (string, bool) {
|
||||
if desc == "" && ocr == "" {
|
||||
return "", false
|
||||
}
|
||||
if desc == "" {
|
||||
return ocr, true
|
||||
}
|
||||
if ocr == "" {
|
||||
return desc, true
|
||||
}
|
||||
if strings.Contains(desc, ocr) {
|
||||
return desc, true
|
||||
}
|
||||
return desc + "\n\n[OCR]\n" + ocr, true
|
||||
}
|
||||
|
||||
// applyIntentPromptOverride resolves the system-prompt override for the current
|
||||
// non-retrieval intent. Agent-level overrides take precedence; otherwise the
|
||||
// tenant/global IntentSystemPrompts map is consulted. Whitespace-only agent
|
||||
// overrides are treated as unset and fall through to the global default. Returns
|
||||
// true when a non-empty override was applied.
|
||||
func applyIntentPromptOverride(chatManage *types.ChatManage, globalPrompts map[string]string) bool {
|
||||
intentKey := string(chatManage.Intent)
|
||||
if raw, ok := chatManage.IntentPromptOverrides[intentKey]; ok && strings.TrimSpace(raw) != "" {
|
||||
chatManage.SystemPromptOverride = raw
|
||||
}
|
||||
if chatManage.SystemPromptOverride == "" {
|
||||
if prompt, ok := globalPrompts[intentKey]; ok {
|
||||
chatManage.SystemPromptOverride = prompt
|
||||
}
|
||||
}
|
||||
return chatManage.SystemPromptOverride != ""
|
||||
}
|
||||
|
||||
// formatConversationHistory formats conversation history for prompt template.
|
||||
func formatConversationHistory(historyList []*types.History) string {
|
||||
if len(historyList) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for _, h := range historyList {
|
||||
builder.WriteString("------BEGIN------\n")
|
||||
builder.WriteString("User question: ")
|
||||
builder.WriteString(h.Query)
|
||||
builder.WriteString("\nAssistant answer: ")
|
||||
builder.WriteString(h.Answer)
|
||||
builder.WriteString("\n------END------\n")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestApplyIntentPromptOverride_AgentOverrideWins(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
IntentPromptOverrides: map[string]string{"chitchat": "agent prompt"},
|
||||
},
|
||||
PipelineState: types.PipelineState{Intent: types.IntentChitchat},
|
||||
}
|
||||
global := map[string]string{"chitchat": "global prompt"}
|
||||
|
||||
if !applyIntentPromptOverride(cm, global) {
|
||||
t.Fatal("expected applied=true")
|
||||
}
|
||||
if cm.SystemPromptOverride != "agent prompt" {
|
||||
t.Errorf("override: got %q, want %q", cm.SystemPromptOverride, "agent prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIntentPromptOverride_PreservesAgentWhitespace(t *testing.T) {
|
||||
// Agent-supplied prompts with surrounding whitespace must reach the model
|
||||
// verbatim; trim is only used for emptiness detection.
|
||||
raw := " agent prompt with trailing newline\n"
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
IntentPromptOverrides: map[string]string{"chitchat": raw},
|
||||
},
|
||||
PipelineState: types.PipelineState{Intent: types.IntentChitchat},
|
||||
}
|
||||
|
||||
if !applyIntentPromptOverride(cm, nil) {
|
||||
t.Fatal("expected applied=true")
|
||||
}
|
||||
if cm.SystemPromptOverride != raw {
|
||||
t.Errorf("override: got %q, want %q", cm.SystemPromptOverride, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIntentPromptOverride_BlankAgentFallsBackToGlobal(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
IntentPromptOverrides: map[string]string{"chitchat": " \n\t "},
|
||||
},
|
||||
PipelineState: types.PipelineState{Intent: types.IntentChitchat},
|
||||
}
|
||||
global := map[string]string{"chitchat": "global prompt"}
|
||||
|
||||
if !applyIntentPromptOverride(cm, global) {
|
||||
t.Fatal("expected applied=true")
|
||||
}
|
||||
if cm.SystemPromptOverride != "global prompt" {
|
||||
t.Errorf("override: got %q, want %q", cm.SystemPromptOverride, "global prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIntentPromptOverride_NoOverrideAndNoGlobal(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{Intent: types.IntentChitchat},
|
||||
}
|
||||
|
||||
if applyIntentPromptOverride(cm, nil) {
|
||||
t.Fatal("expected applied=false")
|
||||
}
|
||||
if cm.SystemPromptOverride != "" {
|
||||
t.Errorf("override should remain empty, got %q", cm.SystemPromptOverride)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIntentPromptOverride_GlobalOnly(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{Intent: types.IntentGreeting},
|
||||
}
|
||||
global := map[string]string{"greeting": "hi there"}
|
||||
|
||||
if !applyIntentPromptOverride(cm, global) {
|
||||
t.Fatal("expected applied=true")
|
||||
}
|
||||
if cm.SystemPromptOverride != "hi there" {
|
||||
t.Errorf("override: got %q, want %q", cm.SystemPromptOverride, "hi there")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOutput_UnparsableFallsBackToOriginalQuery pins the degradation
|
||||
// contract for query understanding: when the LLM returns output that cannot be
|
||||
// parsed as the structured {"rewrite_query","intent",...} JSON, RewriteQuery
|
||||
// must remain the original user query (which OnEvent sets before calling
|
||||
// parseOutput) instead of leaking the raw model text into the downstream
|
||||
// retrieval query.
|
||||
func TestParseOutput_UnparsableFallsBackToOriginalQuery(t *testing.T) {
|
||||
p := &PluginQueryUnderstand{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{
|
||||
RewriteQuery: "original user query",
|
||||
Intent: types.IntentKBSearch,
|
||||
},
|
||||
}
|
||||
|
||||
p.parseOutput(cm, "The answer is: check the admin console")
|
||||
|
||||
if cm.RewriteQuery != "original user query" {
|
||||
t.Fatalf("RewriteQuery = %q, want original user query", cm.RewriteQuery)
|
||||
}
|
||||
if cm.Intent != types.IntentKBSearch {
|
||||
t.Errorf("Intent = %q, want kb_search", cm.Intent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOutput_UnparsableBlankDoesNotRewrite verifies that empty LLM output
|
||||
// also leaves the original query untouched.
|
||||
func TestParseOutput_UnparsableBlankDoesNotRewrite(t *testing.T) {
|
||||
p := &PluginQueryUnderstand{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{
|
||||
RewriteQuery: "original user query",
|
||||
Intent: types.IntentKBSearch,
|
||||
},
|
||||
}
|
||||
|
||||
p.parseOutput(cm, " \n\t ")
|
||||
|
||||
if cm.RewriteQuery != "original user query" {
|
||||
t.Fatalf("RewriteQuery = %q, want original user query", cm.RewriteQuery)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOutput_ValidJSONStillAppliesRewrite guards the happy path: a
|
||||
// well-formed structured output still overrides RewriteQuery and Intent.
|
||||
func TestParseOutput_ValidJSONStillAppliesRewrite(t *testing.T) {
|
||||
p := &PluginQueryUnderstand{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{
|
||||
RewriteQuery: "original user query",
|
||||
Intent: types.IntentKBSearch,
|
||||
},
|
||||
}
|
||||
|
||||
p.parseOutput(cm, `{"rewrite_query":"rewritten query","intent":"summarize"}`)
|
||||
|
||||
if cm.RewriteQuery != "rewritten query" {
|
||||
t.Fatalf("RewriteQuery = %q, want rewritten query", cm.RewriteQuery)
|
||||
}
|
||||
if cm.Intent != types.IntentSummarize {
|
||||
t.Errorf("Intent = %q, want summarize", cm.Intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/modelcontext"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// prepareMessagesWithModelContext replaces positional retrieval IDs with
|
||||
// request-local model handles. Persisted rendered_content remains unchanged;
|
||||
// public citations are expanded only when the request setting enables them.
|
||||
func prepareMessagesWithModelContext(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
) ([]chat.Message, *modelcontext.Registry) {
|
||||
citationsEnabled := chatManage == nil || chatManage.CitationsEnabled()
|
||||
registry := modelcontext.NewRegistry(citationsEnabled)
|
||||
if chatManage == nil {
|
||||
return nil, registry
|
||||
}
|
||||
messages := prepareMessagesWithHistory(chatManage)
|
||||
if len(messages) > 0 {
|
||||
messages[0].Content = strings.TrimRight(messages[0].Content, " \t\r\n") + registry.ProtocolPrompt()
|
||||
}
|
||||
if len(chatManage.MergeResult) == 0 || len(messages) == 0 {
|
||||
return messages, registry
|
||||
}
|
||||
|
||||
ordered := orderedPipelineReferences(chatManage)
|
||||
knowledgeResults := make([]*types.SearchResult, 0, len(ordered))
|
||||
knowledgeRows := make([]map[string]interface{}, 0, len(ordered))
|
||||
webRows := make([]map[string]interface{}, 0)
|
||||
for _, result := range ordered {
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
if isPipelineWebReference(result) {
|
||||
webRows = append(webRows, map[string]interface{}{
|
||||
"url": result.ID,
|
||||
"title": firstPipelineTitle(result),
|
||||
"snippet": result.Content,
|
||||
"published_at": result.Metadata["published_at"],
|
||||
})
|
||||
continue
|
||||
}
|
||||
knowledgeResults = append(knowledgeResults, result)
|
||||
knowledgeRows = append(knowledgeRows, map[string]interface{}{
|
||||
"chunk_id": result.ID,
|
||||
"knowledge_id": result.KnowledgeID,
|
||||
"knowledge_base_id": result.KnowledgeBaseID,
|
||||
"knowledge_title": firstPipelineTitle(result),
|
||||
"chunk_index": result.ChunkIndex,
|
||||
"chunk_type": result.ChunkType,
|
||||
"content": getEnrichedPassageForChat(ctx, result),
|
||||
})
|
||||
}
|
||||
registry.RegisterSearchResults(knowledgeResults)
|
||||
var contextParts []string
|
||||
if len(knowledgeRows) > 0 {
|
||||
contextParts = append(contextParts, registry.ModelToolResult(&types.ToolResult{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"display_type": "search_results",
|
||||
"results": knowledgeRows,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if len(webRows) > 0 {
|
||||
contextParts = append(contextParts, registry.ModelToolResult(&types.ToolResult{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"display_type": "web_search_results",
|
||||
"results": webRows,
|
||||
},
|
||||
}))
|
||||
}
|
||||
modelContexts := strings.Join(contextParts, "\n")
|
||||
if strings.TrimSpace(modelContexts) == "" {
|
||||
return messages, registry
|
||||
}
|
||||
|
||||
last := len(messages) - 1
|
||||
replaced := false
|
||||
for _, index := range []int{0, last} {
|
||||
if chatManage.RenderedContexts != "" && strings.Contains(messages[index].Content, chatManage.RenderedContexts) {
|
||||
messages[index].Content = strings.ReplaceAll(messages[index].Content, chatManage.RenderedContexts, modelContexts)
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
messages[last].Content = modelContexts + "\n\n" + messages[last].Content
|
||||
}
|
||||
return messages, registry
|
||||
}
|
||||
|
||||
func isPipelineWebReference(result *types.SearchResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(result.ChunkType, string(types.ChunkTypeWebSearch)) ||
|
||||
strings.EqualFold(result.KnowledgeSource, "web_search")
|
||||
}
|
||||
|
||||
func orderedPipelineReferences(chatManage *types.ChatManage) []*types.SearchResult {
|
||||
if chatManage == nil {
|
||||
return nil
|
||||
}
|
||||
if !chatManage.FAQPriorityEnabled {
|
||||
return chatManage.MergeResult
|
||||
}
|
||||
ordered := make([]*types.SearchResult, 0, len(chatManage.MergeResult))
|
||||
for _, result := range chatManage.MergeResult {
|
||||
if result != nil && result.ChunkType == string(types.ChunkTypeFAQ) {
|
||||
ordered = append(ordered, result)
|
||||
}
|
||||
}
|
||||
for _, result := range chatManage.MergeResult {
|
||||
if result != nil && result.ChunkType != string(types.ChunkTypeFAQ) {
|
||||
ordered = append(ordered, result)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func firstPipelineTitle(result *types.SearchResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
if result.KnowledgeTitle != "" {
|
||||
return result.KnowledgeTitle
|
||||
}
|
||||
return result.KnowledgeFilename
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPrepareMessagesWithModelContextUsesChunkCentricContext(t *testing.T) {
|
||||
rendered := `<context id="1">first content</context><context id="2">second content</context>`
|
||||
manage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
Query: "question",
|
||||
SummaryConfig: types.SummaryConfig{
|
||||
Prompt: "system",
|
||||
},
|
||||
},
|
||||
PipelineState: types.PipelineState{
|
||||
RenderedContexts: rendered,
|
||||
UserContent: "References:\n" + rendered + "\nQuestion: question",
|
||||
MergeResult: []*types.SearchResult{
|
||||
{ID: "chunk-1", KnowledgeID: "doc-1", KnowledgeBaseID: "kb-1", KnowledgeTitle: "Doc", ChunkIndex: 1, Content: "first content"},
|
||||
{ID: "chunk-2", KnowledgeID: "doc-1", KnowledgeBaseID: "kb-1", KnowledgeTitle: "Doc", ChunkIndex: 2, Content: "second content"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, refs := prepareMessagesWithModelContext(context.Background(), manage)
|
||||
require.Len(t, messages, 2)
|
||||
require.Contains(t, messages[0].Content, "Source handling protocol")
|
||||
require.Contains(t, messages[1].Content, `<document id="d1" kb="b1" title="Doc">`)
|
||||
require.Contains(t, messages[1].Content, `<chunk id="c1" index="1" view="full">`)
|
||||
require.Contains(t, messages[1].Content, `<chunk id="c2" index="2" view="full">`)
|
||||
require.False(t, strings.Contains(messages[1].Content, "chunk-1"))
|
||||
require.Equal(t,
|
||||
`<kb doc="Doc" chunk_id="chunk-1" kb_id="kb-1" />`,
|
||||
refs.DecodeOutputText(`<ref id="c1"/>`),
|
||||
)
|
||||
}
|
||||
|
||||
func TestPrepareMessagesWithModelContextReplacesSystemPromptContextAndHistoryCitations(t *testing.T) {
|
||||
rendered := `<context id="1">first content</context>`
|
||||
manage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
Query: "question",
|
||||
SummaryConfig: types.SummaryConfig{
|
||||
Prompt: `System references: {{contexts}}`,
|
||||
},
|
||||
},
|
||||
PipelineState: types.PipelineState{
|
||||
RenderedContexts: rendered,
|
||||
UserContent: "Question: question",
|
||||
History: []*types.History{{
|
||||
Query: "previous",
|
||||
Answer: `Previous <kb doc="Old" chunk_id="old-chunk" kb_id="old-kb" />`,
|
||||
}},
|
||||
MergeResult: []*types.SearchResult{{
|
||||
ID: "current-chunk", KnowledgeID: "current-doc", KnowledgeBaseID: "current-kb", KnowledgeTitle: "Current", Content: "first content",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
messages, refs := prepareMessagesWithModelContext(context.Background(), manage)
|
||||
messages = refs.EncodeMessages(messages)
|
||||
require.NotContains(t, messages[0].Content, rendered)
|
||||
require.Contains(t, messages[0].Content, `<chunk id="c1"`)
|
||||
require.Contains(t, messages[2].Content, `<ref id="c2"/>`)
|
||||
require.NotContains(t, messages[2].Content, "old-chunk")
|
||||
}
|
||||
|
||||
func TestPrepareMessagesWithModelContextKeepsWebSeparateFromChunks(t *testing.T) {
|
||||
manage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{Query: "question", SummaryConfig: types.SummaryConfig{Prompt: "system"}},
|
||||
PipelineState: types.PipelineState{
|
||||
UserContent: "question",
|
||||
MergeResult: []*types.SearchResult{{
|
||||
ID: "https://example.com/page",
|
||||
KnowledgeTitle: "Example",
|
||||
Content: "web content",
|
||||
ChunkType: string(types.ChunkTypeWebSearch),
|
||||
KnowledgeSource: "web_search",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
messages, refs := prepareMessagesWithModelContext(context.Background(), manage)
|
||||
require.Contains(t, messages[1].Content, `<retrieval type="web" mode="search">`)
|
||||
require.Contains(t, messages[1].Content, `<page id="w1" title="Example">`)
|
||||
require.NotContains(t, messages[1].Content, `<chunk id="c1"`)
|
||||
require.Equal(t,
|
||||
`<web url="https://example.com/page" title="Example" />`,
|
||||
refs.DecodeOutputText(`<ref id="w1"/>`),
|
||||
)
|
||||
}
|
||||
|
||||
func TestPrepareMessagesWithModelContextCompactsHistoryWithoutCurrentRetrieval(t *testing.T) {
|
||||
manage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{Query: "follow-up", SummaryConfig: types.SummaryConfig{Prompt: "system"}},
|
||||
PipelineState: types.PipelineState{
|
||||
UserContent: "follow-up",
|
||||
History: []*types.History{{
|
||||
Query: "previous",
|
||||
Answer: `Previous <web url="https://example.com/old" title="Old" />`,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
messages, refs := prepareMessagesWithModelContext(context.Background(), manage)
|
||||
messages = refs.EncodeMessages(messages)
|
||||
require.Contains(t, messages[0].Content, "Source handling protocol")
|
||||
require.Contains(t, messages[2].Content, `<ref id="w1"/>`)
|
||||
require.NotContains(t, messages[2].Content, "https://example.com/old")
|
||||
}
|
||||
|
||||
func TestPrepareMessagesWithModelContextSuppressesCitationsWhenDisabled(t *testing.T) {
|
||||
disabled := false
|
||||
manage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
Query: "question",
|
||||
CitationEnabled: &disabled,
|
||||
SummaryConfig: types.SummaryConfig{Prompt: "custom system prompt"},
|
||||
},
|
||||
PipelineState: types.PipelineState{
|
||||
UserContent: "question",
|
||||
MergeResult: []*types.SearchResult{{
|
||||
ID: "chunk-1", KnowledgeID: "doc-1", KnowledgeBaseID: "kb-1", KnowledgeTitle: "Doc", Content: "evidence",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
messages, refs := prepareMessagesWithModelContext(context.Background(), manage)
|
||||
require.Contains(t, messages[0].Content, "Source citations are disabled")
|
||||
require.Contains(t, messages[1].Content, `<chunk id="c1"`)
|
||||
require.NotContains(t, messages[1].Content, "chunk-1")
|
||||
require.Equal(t, "answer ", refs.DecodeOutputText(`answer <ref id="c1"/>`))
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/rerank"
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginRerank implements reranking functionality for chat pipeline
|
||||
type PluginRerank struct {
|
||||
modelService interfaces.ModelService // Service to access rerank models
|
||||
}
|
||||
|
||||
// NewPluginRerank creates a new rerank plugin instance
|
||||
func NewPluginRerank(eventManager *EventManager, modelService interfaces.ModelService) *PluginRerank {
|
||||
res := &PluginRerank{
|
||||
modelService: modelService,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginRerank) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHUNK_RERANK}
|
||||
}
|
||||
|
||||
// OnEvent handles reranking events in the chat pipeline
|
||||
func (p *PluginRerank) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
return next()
|
||||
}
|
||||
pipelineInfo(ctx, "Rerank", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"candidate_cnt": len(chatManage.SearchResult),
|
||||
"rerank_model": chatManage.RerankModelID,
|
||||
"rerank_thresh": chatManage.RerankThreshold,
|
||||
"rewrite_query": chatManage.RewriteQuery,
|
||||
})
|
||||
if len(chatManage.SearchResult) == 0 {
|
||||
pipelineInfo(ctx, "Rerank", "skip", map[string]interface{}{
|
||||
"reason": "empty_search_result",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
if chatManage.RerankModelID == "" {
|
||||
pipelineWarn(ctx, "Rerank", "skip", map[string]interface{}{
|
||||
"reason": "empty_model_id",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
// Get rerank model from service
|
||||
rerankModel, err := p.modelService.GetRerankModel(ctx, chatManage.RerankModelID)
|
||||
if err != nil {
|
||||
pipelineError(ctx, "Rerank", "get_model", map[string]interface{}{
|
||||
"model_id": chatManage.RerankModelID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ErrGetRerankModel.WithError(err)
|
||||
}
|
||||
|
||||
// Prepare passages for reranking.
|
||||
var passages []string
|
||||
var candidatesToRerank []*types.SearchResult
|
||||
|
||||
for _, result := range chatManage.SearchResult {
|
||||
passage := getEnrichedPassage(ctx, result)
|
||||
if strings.TrimSpace(passage) == "" {
|
||||
pipelineInfo(ctx, "Rerank", "empty_passage_skip", map[string]interface{}{
|
||||
"chunk_id": result.ID,
|
||||
})
|
||||
continue
|
||||
}
|
||||
passages = append(passages, passage)
|
||||
candidatesToRerank = append(candidatesToRerank, result)
|
||||
}
|
||||
|
||||
passagesPreview := langfuse.SummarizePassagePreviews(candidatesToRerank, passages, 25)
|
||||
rerankCtx, rerankSpan := langfuse.GetManager().StartSpan(ctx, langfuse.SpanOptions{
|
||||
Name: "rerank",
|
||||
Input: map[string]interface{}{
|
||||
"query": chatManage.RewriteQuery,
|
||||
"candidate_count": len(candidatesToRerank),
|
||||
"rerank_model_id": chatManage.RerankModelID,
|
||||
"threshold": chatManage.RerankThreshold,
|
||||
"rerank_top_k": chatManage.RerankTopK,
|
||||
"faq_priority": chatManage.FAQPriorityEnabled,
|
||||
"faq_score_boost": chatManage.FAQScoreBoost,
|
||||
"passages_preview": passagesPreview,
|
||||
},
|
||||
Metadata: map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
},
|
||||
})
|
||||
ctx = rerankCtx
|
||||
spanOutput := map[string]interface{}{}
|
||||
var spanErr error
|
||||
defer func() {
|
||||
rerankSpan.Finish(spanOutput, nil, spanErr)
|
||||
}()
|
||||
|
||||
pipelineInfo(ctx, "Rerank", "build_passages", map[string]interface{}{
|
||||
"total_cnt": len(chatManage.SearchResult),
|
||||
"candidate_cnt": len(candidatesToRerank),
|
||||
})
|
||||
|
||||
var rerankResp []rerank.RankResult
|
||||
var rawRerankResp []rerank.RankResult
|
||||
thresholdDegraded := false
|
||||
|
||||
// Only call rerank model if there are candidates
|
||||
if len(candidatesToRerank) > 0 {
|
||||
// Single rerank call with RewriteQuery, use threshold degradation if no results
|
||||
originalThreshold := chatManage.RerankThreshold
|
||||
var rerankErr error
|
||||
rerankResp, rerankErr = p.rerank(ctx, chatManage, rerankModel, chatManage.RewriteQuery, passages, candidatesToRerank)
|
||||
|
||||
if rerankErr != nil {
|
||||
// Rerank API failed — fallback to original retrieval results so the
|
||||
// pipeline can still return something useful to the caller.
|
||||
pipelineWarn(ctx, "Rerank", "api_error_fallback", map[string]interface{}{
|
||||
"error": rerankErr.Error(),
|
||||
"candidate_cnt": len(candidatesToRerank),
|
||||
})
|
||||
chatManage.SearchResult = candidatesToRerank
|
||||
spanOutput = map[string]interface{}{
|
||||
"stage": "api_error_fallback",
|
||||
"candidate_count": len(candidatesToRerank),
|
||||
"error": rerankErr.Error(),
|
||||
}
|
||||
return next()
|
||||
}
|
||||
rawRerankResp = append([]rerank.RankResult(nil), rerankResp...)
|
||||
|
||||
// If no results and threshold is high enough, try with lower threshold
|
||||
if len(rerankResp) == 0 && originalThreshold > 0.3 {
|
||||
thresholdDegraded = true
|
||||
degradedThreshold := originalThreshold * 0.7
|
||||
if degradedThreshold < 0.3 {
|
||||
degradedThreshold = 0.3
|
||||
}
|
||||
pipelineWarn(ctx, "Rerank", "threshold_degrade", map[string]interface{}{
|
||||
"original": originalThreshold,
|
||||
"degraded": degradedThreshold,
|
||||
"candidate_cnt": len(candidatesToRerank),
|
||||
"reason": "no results above original threshold, retrying with lower threshold",
|
||||
})
|
||||
chatManage.RerankThreshold = degradedThreshold
|
||||
rerankResp, rerankErr = p.rerank(ctx, chatManage, rerankModel, chatManage.RewriteQuery, passages, candidatesToRerank)
|
||||
// Restore original threshold
|
||||
chatManage.RerankThreshold = originalThreshold
|
||||
if rerankErr != nil {
|
||||
pipelineWarn(ctx, "Rerank", "api_error_fallback", map[string]interface{}{
|
||||
"error": rerankErr.Error(),
|
||||
"candidate_cnt": len(candidatesToRerank),
|
||||
})
|
||||
chatManage.SearchResult = candidatesToRerank
|
||||
spanOutput = map[string]interface{}{
|
||||
"stage": "api_error_fallback",
|
||||
"candidate_count": len(candidatesToRerank),
|
||||
"threshold_degraded": thresholdDegraded,
|
||||
"error": rerankErr.Error(),
|
||||
}
|
||||
return next()
|
||||
}
|
||||
rawRerankResp = append([]rerank.RankResult(nil), rerankResp...)
|
||||
}
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Rerank", "model_response", map[string]interface{}{
|
||||
"result_cnt": len(rerankResp),
|
||||
})
|
||||
|
||||
logRerankInputScoreSample(ctx, chatManage.SearchResult)
|
||||
|
||||
for i := range chatManage.SearchResult {
|
||||
chatManage.SearchResult[i].Metadata = ensureMetadata(chatManage.SearchResult[i].Metadata)
|
||||
}
|
||||
reranked := make([]*types.SearchResult, 0, len(rerankResp))
|
||||
|
||||
// Process reranked results
|
||||
for _, rr := range rerankResp {
|
||||
if rr.Index >= len(candidatesToRerank) {
|
||||
continue
|
||||
}
|
||||
sr := candidatesToRerank[rr.Index]
|
||||
base := sr.Score
|
||||
sr.Metadata["base_score"] = fmt.Sprintf("%.4f", base)
|
||||
modelScore := rr.RelevanceScore
|
||||
sr.Metadata["model_score"] = fmt.Sprintf("%.4f", modelScore)
|
||||
sr.Score = compositeScore(sr, modelScore, base)
|
||||
|
||||
// Apply FAQ score boost if enabled
|
||||
if chatManage.FAQPriorityEnabled && chatManage.FAQScoreBoost > 1.0 &&
|
||||
sr.ChunkType == string(types.ChunkTypeFAQ) {
|
||||
originalScore := sr.Score
|
||||
sr.Score = math.Min(sr.Score*chatManage.FAQScoreBoost, 1.0)
|
||||
sr.Metadata["faq_boosted"] = "true"
|
||||
sr.Metadata["faq_original_score"] = fmt.Sprintf("%.4f", originalScore)
|
||||
pipelineInfo(ctx, "Rerank", "faq_boost", map[string]interface{}{
|
||||
"chunk_id": sr.ID,
|
||||
"original_score": fmt.Sprintf("%.4f", originalScore),
|
||||
"boosted_score": fmt.Sprintf("%.4f", sr.Score),
|
||||
"boost_factor": chatManage.FAQScoreBoost,
|
||||
})
|
||||
}
|
||||
|
||||
reranked = append(reranked, sr)
|
||||
}
|
||||
|
||||
final := applyMMR(ctx, reranked, chatManage, min(len(reranked), max(1, chatManage.RerankTopK)), 0.7)
|
||||
chatManage.RerankResult = final
|
||||
|
||||
// Log composite top scores and MMR selection summary
|
||||
topN := min(3, len(reranked))
|
||||
for i := 0; i < topN; i++ {
|
||||
pipelineInfo(ctx, "Rerank", "composite_top", map[string]interface{}{
|
||||
"rank": i + 1,
|
||||
"chunk_id": reranked[i].ID,
|
||||
"base_score": reranked[i].Metadata["base_score"],
|
||||
"final_score": fmt.Sprintf("%.4f", reranked[i].Score),
|
||||
})
|
||||
}
|
||||
|
||||
if len(chatManage.RerankResult) == 0 {
|
||||
pipelineWarn(ctx, "Rerank", "output", map[string]interface{}{
|
||||
"filtered_cnt": 0,
|
||||
})
|
||||
spanOutput = buildRerankSpanOutput(
|
||||
candidatesToRerank,
|
||||
passages,
|
||||
rawRerankResp,
|
||||
reranked,
|
||||
nil,
|
||||
chatManage,
|
||||
thresholdDegraded,
|
||||
)
|
||||
return ErrSearchNothing
|
||||
}
|
||||
|
||||
spanOutput = buildRerankSpanOutput(
|
||||
candidatesToRerank,
|
||||
passages,
|
||||
rawRerankResp,
|
||||
reranked,
|
||||
chatManage.RerankResult,
|
||||
chatManage,
|
||||
thresholdDegraded,
|
||||
)
|
||||
pipelineInfo(ctx, "Rerank", "output", map[string]interface{}{
|
||||
"filtered_cnt": len(chatManage.RerankResult),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
func buildRerankSpanOutput(
|
||||
candidates []*types.SearchResult,
|
||||
passages []string,
|
||||
modelScores []rerank.RankResult,
|
||||
composite []*types.SearchResult,
|
||||
final []*types.SearchResult,
|
||||
chatManage *types.ChatManage,
|
||||
thresholdDegraded bool,
|
||||
) map[string]interface{} {
|
||||
modelRows := make([]map[string]interface{}, 0, len(modelScores))
|
||||
for i, rr := range modelScores {
|
||||
row := map[string]interface{}{
|
||||
"rank": i + 1,
|
||||
"index": rr.Index,
|
||||
"model_score": rr.RelevanceScore,
|
||||
}
|
||||
if rr.Index >= 0 && rr.Index < len(candidates) {
|
||||
row["chunk_id"] = candidates[rr.Index].ID
|
||||
row["knowledge_id"] = candidates[rr.Index].KnowledgeID
|
||||
row["knowledge_title"] = candidates[rr.Index].KnowledgeTitle
|
||||
row["match_type"] = candidates[rr.Index].MatchType
|
||||
row["retrieval_score"] = candidates[rr.Index].Score
|
||||
if rr.Index < len(passages) {
|
||||
row["preview"] = langfuse.TruncateRunes(passages[rr.Index], 160)
|
||||
}
|
||||
}
|
||||
modelRows = append(modelRows, row)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"candidate_count": len(candidates),
|
||||
"model_result_count": len(modelScores),
|
||||
"composite_count": len(composite),
|
||||
"final_count": len(final),
|
||||
"threshold": chatManage.RerankThreshold,
|
||||
"rerank_top_k": chatManage.RerankTopK,
|
||||
"threshold_degraded": thresholdDegraded,
|
||||
"model_scores": langfuse.SummarizeRankScores(modelRows, 50),
|
||||
"composite_results": langfuse.SummarizeSearchResults(composite, 25),
|
||||
"final_results": langfuse.SummarizeSearchResults(final, 25),
|
||||
}
|
||||
if len(modelScores) > 50 {
|
||||
out["model_scores_truncated"] = len(modelScores) - 50
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rerank performs the actual reranking operation with given query and passages
|
||||
func (p *PluginRerank) rerank(ctx context.Context,
|
||||
chatManage *types.ChatManage, rerankModel rerank.Reranker, query string, passages []string,
|
||||
candidates []*types.SearchResult,
|
||||
) ([]rerank.RankResult, error) {
|
||||
pipelineInfo(ctx, "Rerank", "model_call", map[string]interface{}{
|
||||
"query_variant": query,
|
||||
"passages": len(passages),
|
||||
})
|
||||
|
||||
// Filter out empty or whitespace-only passages before sending to the API
|
||||
var cleanPassages []string
|
||||
var cleanCandidates []*types.SearchResult
|
||||
for i, p := range passages {
|
||||
if strings.TrimSpace(p) != "" {
|
||||
cleanPassages = append(cleanPassages, p)
|
||||
if i < len(candidates) {
|
||||
cleanCandidates = append(cleanCandidates, candidates[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cleanPassages) == 0 {
|
||||
pipelineInfo(ctx, "Rerank", "model_call_skip", map[string]interface{}{
|
||||
"reason": "all_passages_empty",
|
||||
})
|
||||
return nil, nil
|
||||
}
|
||||
passages = cleanPassages
|
||||
candidates = cleanCandidates
|
||||
|
||||
rerankResp, err := rerankModel.Rerank(ctx, query, passages)
|
||||
if err != nil {
|
||||
pipelineError(ctx, "Rerank", "model_call", map[string]interface{}{
|
||||
"query_variant": query,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log top scores for debugging
|
||||
pipelineInfo(ctx, "Rerank", "threshold", map[string]interface{}{
|
||||
"threshold": chatManage.RerankThreshold,
|
||||
})
|
||||
logged := min(5, len(rerankResp))
|
||||
for i := range logged {
|
||||
if rerankResp[i].Index < len(candidates) {
|
||||
pipelineInfo(ctx, "Rerank", "top_score", map[string]interface{}{
|
||||
"rank": i + 1,
|
||||
"score": rerankResp[i].RelevanceScore,
|
||||
"chunk_id": candidates[rerankResp[i].Index].ID,
|
||||
"match_type": candidates[rerankResp[i].Index].MatchType,
|
||||
"chunk_type": candidates[rerankResp[i].Index].ChunkType,
|
||||
"content_len": len(candidates[rerankResp[i].Index].Content),
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(rerankResp) > logged {
|
||||
pipelineInfo(ctx, "Rerank", "top_score_summary", map[string]interface{}{
|
||||
"total": len(rerankResp),
|
||||
"logged": logged,
|
||||
"truncated": len(rerankResp) - logged,
|
||||
})
|
||||
}
|
||||
|
||||
// Filter results based on threshold
|
||||
rankFilter := []rerank.RankResult{}
|
||||
for _, result := range rerankResp {
|
||||
if result.Index >= len(candidates) {
|
||||
continue
|
||||
}
|
||||
if result.RelevanceScore >= chatManage.RerankThreshold {
|
||||
rankFilter = append(rankFilter, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if threshold filtering removed all results but the top candidate
|
||||
// still has a reasonable score, keep it as a safety net. Skip fallback entirely
|
||||
// when the best score is too low — forcing irrelevant results is worse than
|
||||
// returning nothing and letting the caller handle the empty-result case.
|
||||
fallbackMinScore := rerankFallbackMinScore(chatManage.SearchTargets)
|
||||
if len(rankFilter) == 0 && len(rerankResp) > 0 && rerankResp[0].RelevanceScore >= fallbackMinScore {
|
||||
rankFilter = rerankResp[:1]
|
||||
pipelineInfo(ctx, "Rerank", "fallback_top1", map[string]interface{}{
|
||||
"reason": "all_below_threshold",
|
||||
"threshold": chatManage.RerankThreshold,
|
||||
"top_score": rerankResp[0].RelevanceScore,
|
||||
})
|
||||
} else if len(rankFilter) == 0 {
|
||||
pipelineInfo(ctx, "Rerank", "fallback_skip", map[string]interface{}{
|
||||
"reason": "top_score_too_low",
|
||||
"threshold": chatManage.RerankThreshold,
|
||||
"top_score": safeTopScore(rerankResp),
|
||||
})
|
||||
}
|
||||
|
||||
return rankFilter, nil
|
||||
}
|
||||
|
||||
func rerankFallbackMinScore(searchTargets types.SearchTargets) float64 {
|
||||
if searchTargets.HasRecallThresholdOverride() {
|
||||
// The user explicitly constrained this turn to a tag/document scope.
|
||||
// Preserve its best candidate instead of letting a global rerank
|
||||
// threshold erase the entire authoritative scope.
|
||||
return 0
|
||||
}
|
||||
return 0.15
|
||||
}
|
||||
|
||||
// ensureMetadata ensures the metadata is not nil
|
||||
func ensureMetadata(m map[string]string) map[string]string {
|
||||
if m == nil {
|
||||
return make(map[string]string)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func safeTopScore(results []rerank.RankResult) float64 {
|
||||
if len(results) == 0 {
|
||||
return 0
|
||||
}
|
||||
return results[0].RelevanceScore
|
||||
}
|
||||
|
||||
// compositeScore calculates the composite score for a search result
|
||||
func compositeScore(sr *types.SearchResult, modelScore, baseScore float64) float64 {
|
||||
sourceWeight := 1.0
|
||||
switch strings.ToLower(sr.KnowledgeSource) {
|
||||
case "web_search":
|
||||
sourceWeight = 0.95
|
||||
default:
|
||||
sourceWeight = 1.0
|
||||
}
|
||||
composite := 0.6*modelScore + 0.3*baseScore + 0.1*sourceWeight
|
||||
if composite < 0 {
|
||||
composite = 0
|
||||
}
|
||||
if composite > 1 {
|
||||
composite = 1
|
||||
}
|
||||
return composite
|
||||
}
|
||||
|
||||
// applyMMR applies the MMR algorithm to the search results with pre-computed token sets
|
||||
func applyMMR(
|
||||
ctx context.Context,
|
||||
results []*types.SearchResult,
|
||||
chatManage *types.ChatManage,
|
||||
k int,
|
||||
lambda float64,
|
||||
) []*types.SearchResult {
|
||||
if k <= 0 || len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
pipelineInfo(ctx, "Rerank", "mmr_start", map[string]interface{}{
|
||||
"lambda": lambda,
|
||||
"k": k,
|
||||
"candidates": len(results),
|
||||
})
|
||||
|
||||
// Pre-compute all token sets concurrently (CPU-bound tokenization)
|
||||
allTokenSets := ParallelMap(results, 0, func(i int, r *types.SearchResult) map[string]struct{} {
|
||||
return searchutil.TokenizeSimple(getEnrichedPassage(ctx, r))
|
||||
})
|
||||
|
||||
selected := make([]*types.SearchResult, 0, k)
|
||||
selectedTokenSets := make([]map[string]struct{}, 0, k)
|
||||
selectedIndices := make(map[int]struct{})
|
||||
|
||||
for len(selected) < k && len(selectedIndices) < len(results) {
|
||||
bestIdx := -1
|
||||
bestScore := -1.0
|
||||
|
||||
for i, r := range results {
|
||||
if _, isSelected := selectedIndices[i]; isSelected {
|
||||
continue
|
||||
}
|
||||
|
||||
relevance := r.Score
|
||||
redundancy := 0.0
|
||||
|
||||
// Use pre-computed token sets for redundancy calculation
|
||||
for _, selTokens := range selectedTokenSets {
|
||||
sim := searchutil.Jaccard(allTokenSets[i], selTokens)
|
||||
if sim > redundancy {
|
||||
redundancy = sim
|
||||
}
|
||||
}
|
||||
|
||||
mmr := lambda*relevance - (1.0-lambda)*redundancy
|
||||
if mmr > bestScore {
|
||||
bestScore = mmr
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
if bestIdx < 0 {
|
||||
break
|
||||
}
|
||||
|
||||
selected = append(selected, results[bestIdx])
|
||||
selectedTokenSets = append(selectedTokenSets, allTokenSets[bestIdx])
|
||||
selectedIndices[bestIdx] = struct{}{}
|
||||
}
|
||||
|
||||
// Compute average redundancy among selected using pre-computed token sets
|
||||
avgRed := 0.0
|
||||
if len(selected) > 1 {
|
||||
pairs := 0
|
||||
for i := 0; i < len(selectedTokenSets); i++ {
|
||||
for j := i + 1; j < len(selectedTokenSets); j++ {
|
||||
avgRed += searchutil.Jaccard(selectedTokenSets[i], selectedTokenSets[j])
|
||||
pairs++
|
||||
}
|
||||
}
|
||||
if pairs > 0 {
|
||||
avgRed /= float64(pairs)
|
||||
}
|
||||
}
|
||||
pipelineInfo(ctx, "Rerank", "mmr_done", map[string]interface{}{
|
||||
"selected": len(selected),
|
||||
"avg_redundancy": fmt.Sprintf("%.4f", avgRed),
|
||||
})
|
||||
return selected
|
||||
}
|
||||
|
||||
// --- Passage cleaning for rerank ---
|
||||
//
|
||||
// Rerank models work on semantic text similarity. Markdown formatting, raw URLs,
|
||||
// image references, table separators, and other structural syntax are noise that
|
||||
// can dilute the semantic signal. The functions below strip this noise before
|
||||
// passages are sent to the rerank model.
|
||||
|
||||
var (
|
||||
// reMarkdownImage matches  — the entire construct is noise.
|
||||
// URL group supports one level of balanced parentheses.
|
||||
reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\([^()\s]*(?:\([^)]*\)[^()\s]*)*\)`)
|
||||
// reLinkedImage matches [](link_url) — unwrap to 
|
||||
// so that the subsequent reMarkdownImage pass can remove the image.
|
||||
reLinkedImage = regexp.MustCompile(
|
||||
`\[!\[([^\]]*)\]\(([^()\s]*(?:\([^)]*\)[^()\s]*)*)\)\]` +
|
||||
`\([^()\s]*(?:\([^)]*\)[^()\s]*)*\)`,
|
||||
)
|
||||
// reMarkdownLink matches [text](url) — we keep the text, drop the URL.
|
||||
// URL group supports one level of balanced parentheses.
|
||||
reMarkdownLink = regexp.MustCompile(`\[([^\]]+)\]\([^()\s]*(?:\([^)]*\)[^()\s]*)*\)`)
|
||||
// reRawURL matches standalone http(s) URLs.
|
||||
reRawURL = regexp.MustCompile(`https?://[^\s)\]>]+`)
|
||||
// reCodeBlock captures the semantic body of fenced code blocks.
|
||||
reCodeBlock = regexp.MustCompile("(?s)```[^\\r\\n]*\\r?\\n(.*?)\\r?\\n?```")
|
||||
// reLatexBlock captures the semantic body of block-level LaTeX ($$...$$).
|
||||
reLatexBlock = regexp.MustCompile(`(?s)\$\$(.*?)\$\$`)
|
||||
// reTableSep matches table separator rows like |---|---|.
|
||||
// Uses [ \t] instead of \s to avoid consuming newlines across rows.
|
||||
reTableSep = regexp.MustCompile(`(?m)^[ \t]*\|[ \t:|-]+\|[ \t]*$`)
|
||||
// reTableRow matches markdown table data rows like | col1 | col2 |.
|
||||
// Uses [ \t] instead of \s to avoid consuming newlines across rows.
|
||||
reTableRow = regexp.MustCompile(`(?m)^[ \t]*\|(.+?)\|[ \t]*$`)
|
||||
// reHeadingPrefix matches leading # markers in headings.
|
||||
reHeadingPrefix = regexp.MustCompile(`(?m)^#{1,6}\s+`)
|
||||
// reBlockquote matches leading > markers.
|
||||
reBlockquote = regexp.MustCompile(`(?m)^>\s?`)
|
||||
// reBoldItalic3 matches ***text*** wrappers (must come before 2 and 1).
|
||||
reBoldItalic3 = regexp.MustCompile(`\*{3}(.+?)\*{3}`)
|
||||
// reBoldItalic2 matches **text** wrappers.
|
||||
reBoldItalic2 = regexp.MustCompile(`\*{2}(.+?)\*{2}`)
|
||||
// reBoldItalic1 matches *text* wrappers.
|
||||
reBoldItalic1 = regexp.MustCompile(`\*(.+?)\*`)
|
||||
// reExcessiveNewlines collapses 3+ consecutive newlines into 2.
|
||||
reExcessiveNewlines = regexp.MustCompile(`\n{3,}`)
|
||||
// reListMarker matches unordered (- , * ) and ordered (1. ) list prefixes.
|
||||
reListMarker = regexp.MustCompile(`(?m)^[\t ]*(?:[-*+]|\d+\.)\s+`)
|
||||
// reHTMLTag matches HTML tags like <br>, <div class="...">, etc.
|
||||
reHTMLTag = regexp.MustCompile(`</?[a-zA-Z][^>]*>`)
|
||||
)
|
||||
|
||||
// cleanPassageForRerank strips markdown/structural noise from text to produce
|
||||
// a clean semantic passage for the rerank model. The cleaning is designed to
|
||||
// preserve all meaningful semantic content while removing formatting
|
||||
// that would confuse text-similarity scoring.
|
||||
func cleanPassageForRerank(text string) string {
|
||||
// 1. Unwrap code blocks so code-only candidates remain rerankable.
|
||||
text = reCodeBlock.ReplaceAllString(text, "$1")
|
||||
// 2. Unwrap LaTeX blocks so formula-only candidates remain rerankable.
|
||||
text = reLatexBlock.ReplaceAllString(text, "$1")
|
||||
// 3. Remove HTML tags
|
||||
text = reHTMLTag.ReplaceAllString(text, "")
|
||||
// 3.5. Unwrap nested [](link_url) → 
|
||||
// so that the next step removes the full construct cleanly.
|
||||
text = reLinkedImage.ReplaceAllString(text, "")
|
||||
// 4. Remove markdown image references entirely
|
||||
text = reMarkdownImage.ReplaceAllString(text, "")
|
||||
// 5. Convert markdown links to just their display text
|
||||
text = reMarkdownLink.ReplaceAllString(text, "$1")
|
||||
// 6. Remove standalone raw URLs
|
||||
text = reRawURL.ReplaceAllString(text, "")
|
||||
// 7. Remove table separator rows
|
||||
text = reTableSep.ReplaceAllString(text, "")
|
||||
// 7.5. Convert table data rows to plain text (strip | delimiters)
|
||||
text = reTableRow.ReplaceAllStringFunc(text, func(match string) string {
|
||||
inner := reTableRow.FindStringSubmatch(match)
|
||||
if len(inner) < 2 {
|
||||
return match
|
||||
}
|
||||
cells := strings.Split(inner[1], "|")
|
||||
var parts []string
|
||||
for _, cell := range cells {
|
||||
cell = strings.TrimSpace(cell)
|
||||
if cell != "" {
|
||||
parts = append(parts, cell)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
})
|
||||
// 8. Strip heading markers but keep heading text
|
||||
text = reHeadingPrefix.ReplaceAllString(text, "")
|
||||
// 9. Strip blockquote markers
|
||||
text = reBlockquote.ReplaceAllString(text, "")
|
||||
// 10. Unwrap bold/italic markers, keeping inner text (order: *** before ** before *)
|
||||
text = reBoldItalic3.ReplaceAllString(text, "$1")
|
||||
text = reBoldItalic2.ReplaceAllString(text, "$1")
|
||||
text = reBoldItalic1.ReplaceAllString(text, "$1")
|
||||
// 11. Strip list markers
|
||||
text = reListMarker.ReplaceAllString(text, "")
|
||||
// 12. Collapse excessive newlines
|
||||
text = reExcessiveNewlines.ReplaceAllString(text, "\n\n")
|
||||
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// getEnrichedPassage 合并Content、ImageInfo和GeneratedQuestions的文本内容
|
||||
func getEnrichedPassage(ctx context.Context, result *types.SearchResult) string {
|
||||
combinedText := cleanPassageForRerank(result.Content)
|
||||
var enrichments []string
|
||||
|
||||
// 解析ImageInfo
|
||||
if result.ImageInfo != "" {
|
||||
var imageInfos []types.ImageInfo
|
||||
err := json.Unmarshal([]byte(result.ImageInfo), &imageInfos)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Rerank", "image_info_parse", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
// 提取所有图片的描述和OCR文本
|
||||
for _, img := range imageInfos {
|
||||
if img.Caption != "" {
|
||||
enrichments = append(enrichments, img.Caption)
|
||||
}
|
||||
if img.OCRText != "" {
|
||||
enrichments = append(enrichments, img.OCRText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析ChunkMetadata中的GeneratedQuestions
|
||||
if len(result.ChunkMetadata) > 0 {
|
||||
var docMeta types.DocumentChunkMetadata
|
||||
err := json.Unmarshal(result.ChunkMetadata, &docMeta)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Rerank", "chunk_metadata_parse", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else if questionStrings := docMeta.GetQuestionStrings(); len(questionStrings) > 0 {
|
||||
enrichments = append(enrichments, strings.Join(questionStrings, "; "))
|
||||
}
|
||||
}
|
||||
|
||||
if len(enrichments) == 0 {
|
||||
return combinedText
|
||||
}
|
||||
|
||||
// 组合内容和增强信息
|
||||
if combinedText != "" {
|
||||
combinedText += "\n\n"
|
||||
}
|
||||
combinedText += strings.Join(enrichments, "\n")
|
||||
|
||||
return combinedText
|
||||
}
|
||||
|
||||
func logRerankInputScoreSample(ctx context.Context, results []*types.SearchResult) {
|
||||
const maxLogRows = 8
|
||||
limit := min(maxLogRows, len(results))
|
||||
for i := 0; i < limit; i++ {
|
||||
sr := results[i]
|
||||
pipelineInfo(ctx, "Rerank", "input_score", map[string]interface{}{
|
||||
"index": i,
|
||||
"chunk_id": sr.ID,
|
||||
"score": fmt.Sprintf("%.4f", sr.Score),
|
||||
"match_type": sr.MatchType,
|
||||
})
|
||||
}
|
||||
if len(results) > limit {
|
||||
pipelineInfo(ctx, "Rerank", "input_score_summary", map[string]interface{}{
|
||||
"total": len(results),
|
||||
"logged": limit,
|
||||
"truncated": len(results) - limit,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestGetEnrichedPassageKeepsQuestionsFromEarlierRevision(t *testing.T) {
|
||||
metadata, err := json.Marshal(types.DocumentChunkMetadata{
|
||||
GeneratedQuestionsRevision: 1,
|
||||
GeneratedQuestions: []types.GeneratedQuestion{{
|
||||
ID: "old", Question: "question generated before the edit",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
passage := getEnrichedPassage(context.Background(), &types.SearchResult{
|
||||
Content: "edited chunk body",
|
||||
ChunkMetadata: types.JSON(metadata),
|
||||
})
|
||||
if !strings.Contains(passage, "question generated before the edit") {
|
||||
t.Fatalf("earlier generated question was excluded from rerank passage: %q", passage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnrichedPassageKeepsCodeAndMathCandidates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "code only",
|
||||
content: "```go\nfunc answer() int { return 42 }\n```",
|
||||
want: "func answer() int { return 42 }",
|
||||
},
|
||||
{
|
||||
name: "math only",
|
||||
content: "$$\nE = mc^2\n$$",
|
||||
want: "E = mc^2",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
passage := getEnrichedPassage(context.Background(), &types.SearchResult{Content: tt.content})
|
||||
if strings.TrimSpace(passage) == "" {
|
||||
t.Fatal("semantic-only candidate was removed from the rerank passage")
|
||||
}
|
||||
if !strings.Contains(passage, tt.want) {
|
||||
t.Fatalf("rerank passage %q does not preserve %q", passage, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPassageForRerank(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "plain text unchanged",
|
||||
input: "这是一段普通的文本内容",
|
||||
expect: "这是一段普通的文本内容",
|
||||
},
|
||||
{
|
||||
name: "remove markdown images",
|
||||
input: "前文  后文",
|
||||
expect: "前文 后文",
|
||||
},
|
||||
{
|
||||
name: "convert markdown links to text",
|
||||
input: "请参考 [官方文档](https://docs.example.com) 了解详情",
|
||||
expect: "请参考 官方文档 了解详情",
|
||||
},
|
||||
{
|
||||
name: "remove standalone URLs",
|
||||
input: "访问 https://example.com/path?q=1&b=2 获取更多信息",
|
||||
expect: "访问 获取更多信息",
|
||||
},
|
||||
{
|
||||
name: "unwrap code blocks",
|
||||
input: "示例代码:\n```python\nprint('hello')\n```\n以上是示例",
|
||||
expect: "示例代码:\nprint('hello')\n以上是示例",
|
||||
},
|
||||
{
|
||||
name: "unwrap LaTeX blocks",
|
||||
input: "公式如下 $$E=mc^2$$ 其中E是能量",
|
||||
expect: "公式如下 E=mc^2 其中E是能量",
|
||||
},
|
||||
{
|
||||
name: "remove table separator rows and convert data rows",
|
||||
input: "| 名称 | 值 |\n| --- | --- |\n| A | 1 |",
|
||||
expect: "名称, 值\n\nA, 1",
|
||||
},
|
||||
{
|
||||
name: "strip heading markers",
|
||||
input: "## 第二章 概述\n### 2.1 背景",
|
||||
expect: "第二章 概述\n2.1 背景",
|
||||
},
|
||||
{
|
||||
name: "strip blockquote markers",
|
||||
input: "> 这是一段引用\n> 第二行引用",
|
||||
expect: "这是一段引用\n第二行引用",
|
||||
},
|
||||
{
|
||||
name: "unwrap bold and italic",
|
||||
input: "这是 **加粗** 和 *斜体* 以及 ***粗斜体*** 文本",
|
||||
expect: "这是 加粗 和 斜体 以及 粗斜体 文本",
|
||||
},
|
||||
{
|
||||
name: "strip list markers",
|
||||
input: "- 项目一\n- 项目二\n1. 有序一\n2. 有序二",
|
||||
expect: "项目一\n项目二\n有序一\n有序二",
|
||||
},
|
||||
{
|
||||
name: "remove HTML tags",
|
||||
input: "文本<br>换行<div class=\"test\">内容</div>结尾",
|
||||
expect: "文本换行内容结尾",
|
||||
},
|
||||
{
|
||||
name: "collapse excessive newlines",
|
||||
input: "段落一\n\n\n\n\n段落二",
|
||||
expect: "段落一\n\n段落二",
|
||||
},
|
||||
{
|
||||
name: "combined real-world passage",
|
||||
input: `## 产品介绍
|
||||
|
||||
这是一个 **重要的** 产品。详见 [产品页面](https://example.com/product)。
|
||||
|
||||

|
||||
|
||||
> 用户评价:非常好用
|
||||
|
||||
- 功能一
|
||||
- 功能二
|
||||
|
||||
` + "```json\n{\"key\": \"value\"}\n```",
|
||||
expect: "产品介绍\n\n这是一个 重要的 产品。详见 产品页面。\n\n用户评价:非常好用\n\n功能一\n功能二\n\n{\"key\": \"value\"}",
|
||||
},
|
||||
{
|
||||
name: "convert table data rows to plain text",
|
||||
input: "| col1 | col2 | col3 |",
|
||||
expect: "col1, col2, col3",
|
||||
},
|
||||
{
|
||||
name: "multi-row table fully converted",
|
||||
input: "| Header1 | Header2 |\n| --- | --- |\n| data1 | data2 |\n| data3 | data4 |",
|
||||
expect: "Header1, Header2\n\ndata1, data2\ndata3, data4",
|
||||
},
|
||||
{
|
||||
name: "table-only passage becomes empty after separator removal",
|
||||
input: "| --- | --- |",
|
||||
expect: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only after cleaning",
|
||||
input: " \n\n ",
|
||||
expect: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := cleanPassageForRerank(tt.input)
|
||||
if got != tt.expect {
|
||||
t.Errorf("cleanPassageForRerank():\ngot: %q\nexpect: %q", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestRerankFallbackMinScoreForExplicitScope(t *testing.T) {
|
||||
if got := rerankFallbackMinScore(nil); got != 0.15 {
|
||||
t.Fatalf("default fallback minimum = %v, want 0.15", got)
|
||||
}
|
||||
|
||||
targets := types.SearchTargets{{DisableRecallThresholds: true}}
|
||||
if got := rerankFallbackMinScore(targets); got != 0 {
|
||||
t.Fatalf("explicit-scope fallback minimum = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginSearch implements search functionality for chat pipeline
|
||||
type PluginSearch struct {
|
||||
knowledgeBaseService interfaces.KnowledgeBaseService
|
||||
knowledgeService interfaces.KnowledgeService
|
||||
chunkService interfaces.ChunkService
|
||||
config *config.Config
|
||||
webSearchService interfaces.WebSearchService
|
||||
tenantService interfaces.TenantService
|
||||
sessionService interfaces.SessionService
|
||||
webSearchStateService interfaces.WebSearchStateService
|
||||
webSearchProviderRepo interfaces.WebSearchProviderRepository
|
||||
}
|
||||
|
||||
func NewPluginSearch(eventManager *EventManager,
|
||||
knowledgeBaseService interfaces.KnowledgeBaseService,
|
||||
knowledgeService interfaces.KnowledgeService,
|
||||
chunkService interfaces.ChunkService,
|
||||
config *config.Config,
|
||||
webSearchService interfaces.WebSearchService,
|
||||
tenantService interfaces.TenantService,
|
||||
sessionService interfaces.SessionService,
|
||||
webSearchStateService interfaces.WebSearchStateService,
|
||||
webSearchProviderRepo interfaces.WebSearchProviderRepository,
|
||||
) *PluginSearch {
|
||||
res := &PluginSearch{
|
||||
knowledgeBaseService: knowledgeBaseService,
|
||||
knowledgeService: knowledgeService,
|
||||
chunkService: chunkService,
|
||||
config: config,
|
||||
webSearchService: webSearchService,
|
||||
tenantService: tenantService,
|
||||
sessionService: sessionService,
|
||||
webSearchStateService: webSearchStateService,
|
||||
webSearchProviderRepo: webSearchProviderRepo,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginSearch) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHUNK_SEARCH}
|
||||
}
|
||||
|
||||
// OnEvent handles search events in the chat pipeline
|
||||
func (p *PluginSearch) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
// Check if we have search targets or web search enabled
|
||||
hasKBTargets := types.HasKnowledgeRetrievalScope(
|
||||
chatManage.SearchTargets,
|
||||
chatManage.KnowledgeBaseIDs,
|
||||
chatManage.KnowledgeIDs,
|
||||
)
|
||||
if !hasKBTargets && !chatManage.WebSearchEnabled {
|
||||
pipelineError(ctx, "Search", "kb_not_found", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Search", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"rewrite_query": chatManage.RewriteQuery,
|
||||
"search_targets": len(chatManage.SearchTargets),
|
||||
"tenant_id": chatManage.TenantID,
|
||||
"web_enabled": chatManage.WebSearchEnabled,
|
||||
})
|
||||
|
||||
// Run KB search and web search concurrently
|
||||
pipelineInfo(ctx, "Search", "plan", map[string]interface{}{
|
||||
"search_targets": len(chatManage.SearchTargets),
|
||||
"embedding_top_k": chatManage.EmbeddingTopK,
|
||||
"vector_threshold": chatManage.VectorThreshold,
|
||||
"keyword_threshold": chatManage.KeywordThreshold,
|
||||
})
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
allResults := make([]*types.SearchResult, 0)
|
||||
var kbSearchErr error
|
||||
|
||||
wg.Add(2)
|
||||
// Goroutine 1: Knowledge base search using SearchTargets
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
kbResults, err := p.searchByTargets(ctx, chatManage)
|
||||
kbSearchErr = err
|
||||
if len(kbResults) > 0 {
|
||||
mu.Lock()
|
||||
allResults = append(allResults, kbResults...)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Goroutine 2: Web search (if enabled)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
webResults := p.searchWebIfEnabled(ctx, chatManage)
|
||||
if len(webResults) > 0 {
|
||||
mu.Lock()
|
||||
allResults = append(allResults, webResults...)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
if kbSearchErr != nil && len(allResults) == 0 {
|
||||
pipelineError(ctx, "Search", "kb_search_failed", map[string]interface{}{
|
||||
"error": kbSearchErr.Error(),
|
||||
})
|
||||
return ErrSearch.WithError(kbSearchErr)
|
||||
}
|
||||
if kbSearchErr != nil {
|
||||
pipelineWarn(ctx, "Search", "kb_search_partial_failure", map[string]interface{}{
|
||||
"error": kbSearchErr.Error(),
|
||||
"result_count": len(allResults),
|
||||
})
|
||||
}
|
||||
|
||||
chatManage.SearchResult = allResults
|
||||
|
||||
logSearchScoreSample(ctx, "result_score_before_normalize", chatManage.SearchResult)
|
||||
|
||||
// If recall is low, attempt query expansion with keyword-focused search
|
||||
if chatManage.EnableQueryExpansion && len(chatManage.SearchResult) < max(1, chatManage.EmbeddingTopK) {
|
||||
expResults := p.runQueryExpansion(ctx, chatManage)
|
||||
if len(expResults) > 0 {
|
||||
chatManage.SearchResult = append(chatManage.SearchResult, expResults...)
|
||||
}
|
||||
}
|
||||
|
||||
logSearchScoreSample(ctx, "final_score", chatManage.SearchResult)
|
||||
|
||||
// Return if we have results
|
||||
if len(chatManage.SearchResult) != 0 {
|
||||
pipelineInfo(ctx, "Search", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"result_count": len(chatManage.SearchResult),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
pipelineWarn(ctx, "Search", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"result_count": 0,
|
||||
})
|
||||
return ErrSearchNothing
|
||||
}
|
||||
|
||||
// getSearchResultFromHistory retrieves relevant knowledge references from chat history
|
||||
func getSearchResultFromHistory(chatManage *types.ChatManage) []*types.SearchResult {
|
||||
if len(chatManage.History) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Search history in reverse chronological order
|
||||
for i := len(chatManage.History) - 1; i >= 0; i-- {
|
||||
if len(chatManage.History[i].KnowledgeReferences) > 0 {
|
||||
// Mark all references as history matches
|
||||
for _, reference := range chatManage.History[i].KnowledgeReferences {
|
||||
reference.MatchType = types.MatchTypeHistory
|
||||
}
|
||||
return chatManage.History[i].KnowledgeReferences
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeDuplicateResults(results []*types.SearchResult) []*types.SearchResult {
|
||||
seen := make(map[string]bool)
|
||||
contentSig := make(map[string]string) // sig -> first chunk ID
|
||||
var uniqueResults []*types.SearchResult
|
||||
for _, r := range results {
|
||||
// Only deduplicate by exact chunk ID — do NOT treat shared ParentChunkID
|
||||
// as duplicates, because different child chunks of the same parent carry
|
||||
// different content segments that may all be relevant.
|
||||
if seen[r.ID] {
|
||||
logger.Debugf(context.Background(), "Dedup: chunk %s removed due to duplicate ID", r.ID)
|
||||
continue
|
||||
}
|
||||
sig := buildContentSignature(r.Content)
|
||||
if sig != "" {
|
||||
if firstChunk, exists := contentSig[sig]; exists {
|
||||
logger.Debugf(context.Background(), "Dedup: chunk %s removed due to content signature (dup of %s, sig prefix: %.50s...)", r.ID, firstChunk, sig)
|
||||
continue
|
||||
}
|
||||
contentSig[sig] = r.ID
|
||||
}
|
||||
seen[r.ID] = true
|
||||
uniqueResults = append(uniqueResults, r)
|
||||
}
|
||||
return uniqueResults
|
||||
}
|
||||
|
||||
func buildContentSignature(content string) string {
|
||||
return searchutil.BuildContentSignature(content)
|
||||
}
|
||||
|
||||
// removePartialOverlaps drops chunks whose content is largely contained within
|
||||
// a higher-scored chunk, even across different knowledge sources. This catches
|
||||
// cross-KB duplicates and near-duplicates that exact-signature dedup misses.
|
||||
//
|
||||
// Two thresholds are used:
|
||||
// - Substring containment: if the normalized short text is a literal substring
|
||||
// of the normalized long text, the shorter chunk is removed.
|
||||
// - Token overlap coefficient >= 0.85: if 85%+ of the smaller chunk's tokens
|
||||
// appear in the larger chunk, the smaller one is redundant.
|
||||
//
|
||||
// The input slice MUST already be deduplicated by ID/signature. Within each
|
||||
// pair the chunk with the lower score is the candidate for removal; ties are
|
||||
// broken by content length (longer wins).
|
||||
func removePartialOverlaps(ctx context.Context, results []*types.SearchResult) []*types.SearchResult {
|
||||
const overlapThreshold = 0.85
|
||||
|
||||
if len(results) <= 1 {
|
||||
return results
|
||||
}
|
||||
|
||||
type normEntry struct {
|
||||
norm string
|
||||
result *types.SearchResult
|
||||
}
|
||||
|
||||
entries := make([]normEntry, 0, len(results))
|
||||
for _, r := range results {
|
||||
entries = append(entries, normEntry{
|
||||
norm: searchutil.NormalizeContent(r.Content),
|
||||
result: r,
|
||||
})
|
||||
}
|
||||
|
||||
removed := make(map[int]bool)
|
||||
|
||||
for i := 0; i < len(entries); i++ {
|
||||
if removed[i] {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(entries); j++ {
|
||||
if removed[j] {
|
||||
continue
|
||||
}
|
||||
|
||||
a, b := entries[i], entries[j]
|
||||
|
||||
shortIdx, longIdx := i, j
|
||||
if len(a.norm) > len(b.norm) {
|
||||
shortIdx, longIdx = j, i
|
||||
}
|
||||
|
||||
contained := searchutil.IsContentContained(
|
||||
entries[shortIdx].norm, entries[longIdx].norm,
|
||||
)
|
||||
|
||||
if !contained {
|
||||
ratio := searchutil.ContentOverlapRatio(
|
||||
entries[shortIdx].result.Content,
|
||||
entries[longIdx].result.Content,
|
||||
)
|
||||
if ratio < overlapThreshold {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
victim := shortIdx
|
||||
if entries[shortIdx].result.Score > entries[longIdx].result.Score {
|
||||
victim = longIdx
|
||||
}
|
||||
removed[victim] = true
|
||||
|
||||
keptIdx := i
|
||||
if victim == i {
|
||||
keptIdx = j
|
||||
}
|
||||
pipelineInfo(ctx, "Merge", "partial_overlap_drop", map[string]interface{}{
|
||||
"kept_id": entries[keptIdx].result.ID,
|
||||
"dropped_id": entries[victim].result.ID,
|
||||
"contained": contained,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]*types.SearchResult, 0, len(results)-len(removed))
|
||||
for i, e := range entries {
|
||||
if !removed[i] {
|
||||
out = append(out, e.result)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func logSearchScoreSample(ctx context.Context, action string, results []*types.SearchResult) {
|
||||
const maxLogRows = 8
|
||||
limit := min(maxLogRows, len(results))
|
||||
for i := 0; i < limit; i++ {
|
||||
r := results[i]
|
||||
pipelineInfo(ctx, "Search", action, map[string]interface{}{
|
||||
"index": i,
|
||||
"chunk_id": r.ID,
|
||||
"score": fmt.Sprintf("%.4f", r.Score),
|
||||
"match_type": r.MatchType,
|
||||
})
|
||||
}
|
||||
if len(results) > limit {
|
||||
pipelineInfo(ctx, "Search", action+"_summary", map[string]interface{}{
|
||||
"total": len(results),
|
||||
"logged": limit,
|
||||
"truncated": len(results) - limit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// targetReportsEmbedFailure reports whether an embedding failure for a KB should
|
||||
// be recorded as a retrieval error. Wiki/graph-only KBs have no vector or keyword
|
||||
// index to degrade into; HybridSearch returns empty without error. When KB metadata
|
||||
// is unavailable, callers still attempt keyword-degraded search.
|
||||
func targetReportsEmbedFailure(kb *types.KnowledgeBase) bool {
|
||||
if kb == nil {
|
||||
return false
|
||||
}
|
||||
if kb.Type == types.KnowledgeBaseTypeFAQ {
|
||||
return true
|
||||
}
|
||||
if kb.IsKeywordEnabled() {
|
||||
return false
|
||||
}
|
||||
return kb.IsVectorEnabled()
|
||||
}
|
||||
|
||||
// searchByTargets performs KB searches using pre-computed SearchTargets.
|
||||
// Targets sharing the same underlying embedding model (identified by model
|
||||
// name + endpoint, not just model ID) are grouped so the query embedding is
|
||||
// computed once per model AND all full-KB targets in a group are combined into
|
||||
// a single retrieval call, reducing both embedding API calls and DB round-trips.
|
||||
func (p *PluginSearch) searchByTargets(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
) ([]*types.SearchResult, error) {
|
||||
if len(chatManage.SearchTargets) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
queryText := strings.TrimSpace(chatManage.RewriteQuery)
|
||||
|
||||
// Batch-fetch KB records to determine embedding model grouping.
|
||||
// On failure, all targets fall into an empty-key group and HybridSearch
|
||||
// computes the embedding per-KB (graceful degradation).
|
||||
kbIDs := make([]string, 0, len(chatManage.SearchTargets))
|
||||
for _, t := range chatManage.SearchTargets {
|
||||
kbIDs = append(kbIDs, t.KnowledgeBaseID)
|
||||
}
|
||||
var kbList []*types.KnowledgeBase
|
||||
kbMap := make(map[string]*types.KnowledgeBase)
|
||||
if kbs, err := p.knowledgeBaseService.GetKnowledgeBasesByIDsOnly(ctx, kbIDs); err == nil {
|
||||
kbList = kbs
|
||||
for _, kb := range kbs {
|
||||
if kb != nil {
|
||||
kbMap[kb.ID] = kb
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pipelineWarn(ctx, "Search", "batch_kb_fetch_error", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve actual model identities (name + endpoint) so that cross-tenant
|
||||
// KBs backed by the same physical model share one embedding computation.
|
||||
modelKeyMap := p.knowledgeBaseService.ResolveEmbeddingModelKeys(ctx, kbList)
|
||||
|
||||
groups := make(map[string][]*types.SearchTarget)
|
||||
for _, t := range chatManage.SearchTargets {
|
||||
key := modelKeyMap[t.KnowledgeBaseID] // empty string if unresolved
|
||||
groups[key] = append(groups[key], t)
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Search", "embedding_groups", map[string]interface{}{
|
||||
"total_targets": len(chatManage.SearchTargets),
|
||||
"unique_models": len(groups),
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var results []*types.SearchResult
|
||||
var firstErr error
|
||||
var errOnce sync.Once
|
||||
recordError := func(err error) {
|
||||
if err != nil {
|
||||
errOnce.Do(func() { firstErr = err })
|
||||
}
|
||||
}
|
||||
|
||||
for modelKey, targets := range groups {
|
||||
wg.Add(1)
|
||||
go func(modelKey string, targets []*types.SearchTarget) {
|
||||
defer wg.Done()
|
||||
|
||||
// Compute embedding once for this model group. When that fails, retain
|
||||
// only targets that have a real keyword index; vector-only targets must
|
||||
// propagate the embedding failure instead of looking like empty recall.
|
||||
var queryEmbedding []float32
|
||||
disableVector := false
|
||||
searchableTargets := targets
|
||||
if modelKey != "" {
|
||||
emb, err := p.knowledgeBaseService.GetQueryEmbedding(ctx, targets[0].KnowledgeBaseID, queryText)
|
||||
if err != nil {
|
||||
searchableTargets = make([]*types.SearchTarget, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
kb := kbMap[target.KnowledgeBaseID]
|
||||
if !targetReportsEmbedFailure(kb) {
|
||||
searchableTargets = append(searchableTargets, target)
|
||||
continue
|
||||
}
|
||||
recordError(fmt.Errorf("knowledge base %s has no keyword fallback: %w", target.KnowledgeBaseID, err))
|
||||
}
|
||||
pipelineWarn(ctx, "Search", "group_embed_degrade_keyword", map[string]interface{}{
|
||||
"model_key": modelKey,
|
||||
"kb_id": targets[0].KnowledgeBaseID,
|
||||
"error": err.Error(),
|
||||
"fallback_targets": len(searchableTargets),
|
||||
"failed_targets": len(targets) - len(searchableTargets),
|
||||
})
|
||||
disableVector = true
|
||||
} else {
|
||||
queryEmbedding = emb
|
||||
}
|
||||
}
|
||||
|
||||
// Separate full-KB targets (can be combined into one retrieval)
|
||||
// from specific-knowledge targets (need per-target direct loading).
|
||||
var fullKBIDs []string
|
||||
var knowledgeTargets []*types.SearchTarget
|
||||
for _, t := range searchableTargets {
|
||||
if t.Type == types.SearchTargetTypeKnowledgeBase && len(t.TagIDs) == 0 {
|
||||
fullKBIDs = append(fullKBIDs, t.KnowledgeBaseID)
|
||||
} else {
|
||||
knowledgeTargets = append(knowledgeTargets, t)
|
||||
}
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Search", "group_plan", map[string]interface{}{
|
||||
"model_key": modelKey,
|
||||
"combined_kb_count": len(fullKBIDs),
|
||||
"individual_targets": len(knowledgeTargets),
|
||||
"vector_len": len(queryEmbedding),
|
||||
})
|
||||
|
||||
var innerWg sync.WaitGroup
|
||||
|
||||
// Combined search: one HybridSearch call spanning all full-KB targets
|
||||
if len(fullKBIDs) > 0 {
|
||||
innerWg.Add(1)
|
||||
go func() {
|
||||
defer innerWg.Done()
|
||||
|
||||
params := types.SearchParams{
|
||||
QueryText: queryText,
|
||||
QueryEmbedding: queryEmbedding,
|
||||
KnowledgeBaseIDs: fullKBIDs,
|
||||
VectorThreshold: chatManage.VectorThreshold,
|
||||
KeywordThreshold: chatManage.KeywordThreshold,
|
||||
MatchCount: chatManage.EmbeddingTopK,
|
||||
SkipContextEnrichment: true,
|
||||
DisableVectorMatch: disableVector,
|
||||
}
|
||||
res, err := p.knowledgeBaseService.HybridSearch(ctx, fullKBIDs[0], params)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Search", "combined_kb_search_error", map[string]interface{}{
|
||||
"kb_ids": fullKBIDs,
|
||||
"error": err.Error(),
|
||||
})
|
||||
recordError(err)
|
||||
return
|
||||
}
|
||||
pipelineInfo(ctx, "Search", "combined_kb_result", map[string]interface{}{
|
||||
"kb_ids": fullKBIDs,
|
||||
"hit_count": len(res),
|
||||
})
|
||||
mu.Lock()
|
||||
results = append(results, res...)
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
// Individual search: per-target handling for specific-knowledge targets
|
||||
for _, target := range knowledgeTargets {
|
||||
innerWg.Add(1)
|
||||
go func(t *types.SearchTarget) {
|
||||
defer innerWg.Done()
|
||||
recordError(p.searchSingleTarget(
|
||||
ctx, chatManage, t, queryText, queryEmbedding, disableVector, &mu, &results,
|
||||
))
|
||||
}(target)
|
||||
}
|
||||
|
||||
innerWg.Wait()
|
||||
}(modelKey, targets)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
pipelineInfo(ctx, "Search", "kb_result_summary", map[string]interface{}{
|
||||
"total_hits": len(results),
|
||||
})
|
||||
return results, firstErr
|
||||
}
|
||||
|
||||
// searchSingleTarget performs hybrid retrieval inside one constrained target.
|
||||
func (p *PluginSearch) searchSingleTarget(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
t *types.SearchTarget,
|
||||
queryText string,
|
||||
queryEmbedding []float32,
|
||||
disableVector bool,
|
||||
mu *sync.Mutex,
|
||||
results *[]*types.SearchResult,
|
||||
) error {
|
||||
if t.Type == types.SearchTargetTypeKnowledge && len(t.KnowledgeIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
vectorThreshold, keywordThreshold := t.RecallThresholds(
|
||||
chatManage.VectorThreshold,
|
||||
chatManage.KeywordThreshold,
|
||||
)
|
||||
if t.DisableRecallThresholds {
|
||||
pipelineInfo(ctx, "Search", "explicit_scope_threshold_override", map[string]interface{}{
|
||||
"kb_id": t.KnowledgeBaseID,
|
||||
"knowledge_id_count": len(t.KnowledgeIDs),
|
||||
"tag_id_count": len(t.TagIDs),
|
||||
})
|
||||
}
|
||||
params := types.SearchParams{
|
||||
QueryText: queryText,
|
||||
QueryEmbedding: queryEmbedding,
|
||||
VectorThreshold: vectorThreshold,
|
||||
KeywordThreshold: keywordThreshold,
|
||||
MatchCount: chatManage.EmbeddingTopK,
|
||||
TagIDs: t.TagIDs,
|
||||
ScopeTagIDs: t.ScopeTagIDs,
|
||||
SkipContextEnrichment: true,
|
||||
DisableVectorMatch: disableVector,
|
||||
}
|
||||
if t.Type == types.SearchTargetTypeKnowledge {
|
||||
params.KnowledgeIDs = t.KnowledgeIDs
|
||||
}
|
||||
res, err := p.knowledgeBaseService.HybridSearch(ctx, t.KnowledgeBaseID, params)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Search", "kb_search_error", map[string]interface{}{
|
||||
"kb_id": t.KnowledgeBaseID,
|
||||
"target_type": t.Type,
|
||||
"query": params.QueryText,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
pipelineInfo(ctx, "Search", "kb_result", map[string]interface{}{
|
||||
"kb_id": t.KnowledgeBaseID,
|
||||
"target_type": t.Type,
|
||||
"hit_count": len(res),
|
||||
})
|
||||
mu.Lock()
|
||||
*results = append(*results, res...)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchWebIfEnabled executes web search when enabled and returns converted results
|
||||
func (p *PluginSearch) searchWebIfEnabled(ctx context.Context, chatManage *types.ChatManage) []*types.SearchResult {
|
||||
if !chatManage.WebSearchEnabled || p.webSearchService == nil || p.tenantService == nil {
|
||||
return nil
|
||||
}
|
||||
tenant, _ := types.TenantInfoFromContext(ctx)
|
||||
providerID := chatManage.WebSearchProviderID
|
||||
|
||||
if providerID == "" {
|
||||
pipelineWarn(ctx, "Search", "web_config_missing", map[string]interface{}{
|
||||
"tenant_id": chatManage.TenantID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
webConfig := types.EffectiveWebSearchConfig(nil)
|
||||
if tenant != nil {
|
||||
webConfig = types.EffectiveWebSearchConfig(tenant.WebSearchConfig)
|
||||
}
|
||||
|
||||
// Apply agent-level web search overrides
|
||||
if chatManage.WebSearchMaxResults > 0 {
|
||||
webConfig.MaxResults = chatManage.WebSearchMaxResults
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Search", "web_request", map[string]interface{}{
|
||||
"tenant_id": chatManage.TenantID,
|
||||
"provider_id": providerID,
|
||||
})
|
||||
webCtx, webSpan := langfuse.GetManager().StartSpan(ctx, langfuse.SpanOptions{
|
||||
Name: "web_search",
|
||||
Input: map[string]interface{}{
|
||||
"provider_id": providerID,
|
||||
"query": chatManage.RewriteQuery,
|
||||
"max_results": webConfig.MaxResults,
|
||||
},
|
||||
})
|
||||
webResults, err := p.webSearchService.Search(webCtx, providerID, webConfig, chatManage.RewriteQuery)
|
||||
webSpan.Finish(map[string]interface{}{
|
||||
"hit_count": len(webResults),
|
||||
}, nil, err)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Search", "web_search_error", map[string]interface{}{
|
||||
"tenant_id": chatManage.TenantID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// Build questions using RewriteQuery only
|
||||
// questions := []string{strings.TrimSpace(chatManage.RewriteQuery)}
|
||||
// Load session-scoped temp KB state from Redis using WebSearchStateRepository
|
||||
// tempKBID, seen, ids := p.webSearchStateService.GetWebSearchTempKBState(ctx, chatManage.SessionID)
|
||||
// compressed, kbID, newSeen, newIDs, err := p.webSearchService.CompressWithRAG(
|
||||
// ctx, chatManage.SessionID, tempKBID, questions, webResults, webConfig,
|
||||
// p.knowledgeBaseService, p.knowledgeService, seen, ids,
|
||||
// )
|
||||
// if err != nil {
|
||||
// pipelineWarn(ctx, "Search", "web_compress_error", map[string]interface{}{
|
||||
// "error": err.Error(),
|
||||
// })
|
||||
// } else {
|
||||
// webResults = compressed
|
||||
// // Persist temp KB state back into Redis using WebSearchStateRepository
|
||||
// p.webSearchStateService.SaveWebSearchTempKBState(ctx, chatManage.SessionID, kbID, newSeen, newIDs)
|
||||
// }
|
||||
res := searchutil.ConvertWebSearchResults(webResults)
|
||||
pipelineInfo(ctx, "Search", "web_hits", map[string]interface{}{
|
||||
"hit_count": len(res),
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginSearch implements search functionality for chat pipeline
|
||||
type PluginSearchEntity struct {
|
||||
graphRepo interfaces.RetrieveGraphRepository
|
||||
chunkRepo interfaces.ChunkRepository
|
||||
knowledgeRepo interfaces.KnowledgeRepository
|
||||
}
|
||||
|
||||
// NewPluginSearchEntity creates a new plugin search entity
|
||||
func NewPluginSearchEntity(
|
||||
eventManager *EventManager,
|
||||
graphRepository interfaces.RetrieveGraphRepository,
|
||||
chunkRepository interfaces.ChunkRepository,
|
||||
knowledgeRepository interfaces.KnowledgeRepository,
|
||||
) *PluginSearchEntity {
|
||||
res := &PluginSearchEntity{
|
||||
graphRepo: graphRepository,
|
||||
chunkRepo: chunkRepository,
|
||||
knowledgeRepo: knowledgeRepository,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the list of event types this plugin responds to
|
||||
func (p *PluginSearchEntity) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.ENTITY_SEARCH}
|
||||
}
|
||||
|
||||
// OnEvent processes triggered events
|
||||
func (p *PluginSearchEntity) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
entity := chatManage.Entity
|
||||
if len(entity) == 0 {
|
||||
logger.Infof(ctx, "No entity found")
|
||||
return next()
|
||||
}
|
||||
|
||||
// Use EntityKBIDs (knowledge bases with ExtractConfig enabled)
|
||||
knowledgeBaseIDs := chatManage.EntityKBIDs
|
||||
// Use EntityKnowledge (KnowledgeID -> KnowledgeBaseID mapping for graph-enabled files)
|
||||
entityKnowledge := chatManage.EntityKnowledge
|
||||
|
||||
if len(knowledgeBaseIDs) == 0 && len(entityKnowledge) == 0 {
|
||||
logger.Warnf(ctx, "No knowledge base IDs or knowledge IDs with ExtractConfig enabled for entity search")
|
||||
return next()
|
||||
}
|
||||
|
||||
// Parallel search across multiple knowledge bases and individual files
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var allNodes []*types.GraphNode
|
||||
var allRelations []*types.GraphRelation
|
||||
|
||||
// If specific KnowledgeIDs are provided, search by individual files
|
||||
if len(entityKnowledge) > 0 {
|
||||
logger.Infof(ctx, "Searching entities across %d knowledge file(s)", len(entityKnowledge))
|
||||
for knowledgeID, kbID := range entityKnowledge {
|
||||
wg.Add(1)
|
||||
go func(knowledgeBaseID, knowledgeID string) {
|
||||
defer wg.Done()
|
||||
|
||||
graph, err := p.graphRepo.SearchNode(ctx, types.NameSpace{
|
||||
KnowledgeBase: knowledgeBaseID,
|
||||
Knowledge: knowledgeID,
|
||||
}, entity)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to search entity in Knowledge %s: %v", knowledgeID, err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof(
|
||||
ctx,
|
||||
"Knowledge %s entity search result count: %d nodes, %d relations",
|
||||
knowledgeID,
|
||||
len(graph.Node),
|
||||
len(graph.Relation),
|
||||
)
|
||||
|
||||
mu.Lock()
|
||||
allNodes = append(allNodes, graph.Node...)
|
||||
allRelations = append(allRelations, graph.Relation...)
|
||||
mu.Unlock()
|
||||
}(kbID, knowledgeID)
|
||||
}
|
||||
} else {
|
||||
// Otherwise, search by knowledge base
|
||||
logger.Infof(ctx, "Searching entities across %d knowledge base(s): %v", len(knowledgeBaseIDs), knowledgeBaseIDs)
|
||||
for _, kbID := range knowledgeBaseIDs {
|
||||
wg.Add(1)
|
||||
go func(knowledgeBaseID string) {
|
||||
defer wg.Done()
|
||||
|
||||
graph, err := p.graphRepo.SearchNode(ctx, types.NameSpace{KnowledgeBase: knowledgeBaseID}, entity)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to search entity in KB %s: %v", knowledgeBaseID, err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof(
|
||||
ctx,
|
||||
"KB %s entity search result count: %d nodes, %d relations",
|
||||
knowledgeBaseID,
|
||||
len(graph.Node),
|
||||
len(graph.Relation),
|
||||
)
|
||||
|
||||
mu.Lock()
|
||||
allNodes = append(allNodes, graph.Node...)
|
||||
allRelations = append(allRelations, graph.Relation...)
|
||||
mu.Unlock()
|
||||
}(kbID)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Merge graph data
|
||||
chatManage.GraphResult = &types.GraphData{
|
||||
Node: allNodes,
|
||||
Relation: allRelations,
|
||||
}
|
||||
logger.Infof(ctx, "Total entity search result: %d nodes, %d relations", len(allNodes), len(allRelations))
|
||||
|
||||
chunkIDs := filterSeenChunk(ctx, chatManage.GraphResult, chatManage.SearchResult)
|
||||
if len(chunkIDs) == 0 {
|
||||
logger.Infof(ctx, "No new chunk found")
|
||||
return next()
|
||||
}
|
||||
chunks, err := p.chunkRepo.ListChunksByID(ctx, types.MustTenantIDFromContext(ctx), chunkIDs)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to list chunks, session_id: %s, error: %v", chatManage.SessionID, err)
|
||||
return next()
|
||||
}
|
||||
knowledgeIDs := []string{}
|
||||
for _, chunk := range chunks {
|
||||
knowledgeIDs = append(knowledgeIDs, chunk.KnowledgeID)
|
||||
}
|
||||
knowledges, err := p.knowledgeRepo.GetKnowledgeBatch(
|
||||
ctx,
|
||||
types.MustTenantIDFromContext(ctx),
|
||||
knowledgeIDs,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to list knowledge, session_id: %s, error: %v", chatManage.SessionID, err)
|
||||
return next()
|
||||
}
|
||||
|
||||
knowledgeMap := map[string]*types.Knowledge{}
|
||||
for _, knowledge := range knowledges {
|
||||
knowledgeMap[knowledge.ID] = knowledge
|
||||
}
|
||||
var entityResults []*types.SearchResult
|
||||
for _, chunk := range chunks {
|
||||
searchResult := chunk2SearchResult(chunk, knowledgeMap[chunk.KnowledgeID])
|
||||
entityResults = append(entityResults, searchResult)
|
||||
}
|
||||
searchutil.EnrichSearchResultsImageInfo(ctx, p.chunkRepo, types.MustTenantIDFromContext(ctx), entityResults)
|
||||
chatManage.SearchResult = append(chatManage.SearchResult, entityResults...)
|
||||
// remove duplicate results
|
||||
chatManage.SearchResult = removeDuplicateResults(chatManage.SearchResult)
|
||||
if len(chatManage.SearchResult) == 0 {
|
||||
logger.Infof(ctx, "No new search result, session_id: %s", chatManage.SessionID)
|
||||
return ErrSearchNothing
|
||||
}
|
||||
logger.Infof(
|
||||
ctx,
|
||||
"search entity result count: %d, session_id: %s",
|
||||
len(chatManage.SearchResult),
|
||||
chatManage.SessionID,
|
||||
)
|
||||
return next()
|
||||
}
|
||||
|
||||
// filterSeenChunk filters seen chunks from the graph
|
||||
func filterSeenChunk(ctx context.Context, graph *types.GraphData, searchResult []*types.SearchResult) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, chunk := range searchResult {
|
||||
seen[chunk.ID] = true
|
||||
}
|
||||
logger.Infof(ctx, "filterSeenChunk: seen count: %d", len(seen))
|
||||
|
||||
chunkIDs := []string{}
|
||||
for _, node := range graph.Node {
|
||||
for _, chunkID := range node.Chunks {
|
||||
if seen[chunkID] {
|
||||
continue
|
||||
}
|
||||
seen[chunkID] = true
|
||||
chunkIDs = append(chunkIDs, chunkID)
|
||||
}
|
||||
}
|
||||
logger.Infof(ctx, "filterSeenChunk: new chunkIDs count: %d", len(chunkIDs))
|
||||
return chunkIDs
|
||||
}
|
||||
|
||||
// chunk2SearchResult converts a chunk to a search result
|
||||
func chunk2SearchResult(chunk *types.Chunk, knowledge *types.Knowledge) *types.SearchResult {
|
||||
return &types.SearchResult{
|
||||
ID: chunk.ID,
|
||||
Content: chunk.Content,
|
||||
ContentRevision: chunk.ContentRevision,
|
||||
KnowledgeID: chunk.KnowledgeID,
|
||||
ChunkIndex: chunk.ChunkIndex,
|
||||
KnowledgeTitle: knowledge.Title,
|
||||
StartAt: chunk.StartAt,
|
||||
EndAt: chunk.EndAt,
|
||||
Seq: chunk.ChunkIndex,
|
||||
Score: 1.0,
|
||||
MatchType: types.MatchTypeGraph,
|
||||
Metadata: knowledge.GetMetadata(),
|
||||
ChunkType: string(chunk.ChunkType),
|
||||
ParentChunkID: chunk.ParentChunkID,
|
||||
ImageInfo: chunk.ImageInfo,
|
||||
KnowledgeFilename: knowledge.FileName,
|
||||
KnowledgeSource: knowledge.Source,
|
||||
KnowledgeChannel: knowledge.Channel,
|
||||
ChunkMetadata: chunk.Metadata,
|
||||
KnowledgeBaseID: knowledge.KnowledgeBaseID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
type failingSearchKnowledgeBaseService struct {
|
||||
interfaces.KnowledgeBaseService
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *failingSearchKnowledgeBaseService) GetKnowledgeBasesByIDsOnly(
|
||||
context.Context, []string,
|
||||
) ([]*types.KnowledgeBase, error) {
|
||||
return []*types.KnowledgeBase{{
|
||||
ID: "kb-1",
|
||||
Type: types.KnowledgeBaseTypeDocument,
|
||||
EmbeddingModelID: "embedding-1",
|
||||
IndexingStrategy: types.DefaultIndexingStrategy(),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (s *failingSearchKnowledgeBaseService) ResolveEmbeddingModelKeys(
|
||||
context.Context, []*types.KnowledgeBase,
|
||||
) map[string]string {
|
||||
return map[string]string{"kb-1": "doubao|https://ark.cn-beijing.volces.com/api/v3"}
|
||||
}
|
||||
|
||||
func (s *failingSearchKnowledgeBaseService) GetQueryEmbedding(
|
||||
context.Context, string, string,
|
||||
) ([]float32, error) {
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
func (s *failingSearchKnowledgeBaseService) HybridSearch(
|
||||
context.Context, string, types.SearchParams,
|
||||
) ([]*types.SearchResult, error) {
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
// embedding 失败会先降级为关键词检索;本用例中关键词检索也失败,
|
||||
// 此时必须仍报 search_failed 并保留根因,而不是伪装成"无结果"。
|
||||
func TestSearchEmbeddingFailureIsNotReportedAsNoResults(t *testing.T) {
|
||||
rootCause := errors.New("embedding endpoint unavailable")
|
||||
plugin := &PluginSearch{
|
||||
knowledgeBaseService: &failingSearchKnowledgeBaseService{err: rootCause},
|
||||
}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SearchTargets: types.SearchTargets{{
|
||||
Type: types.SearchTargetTypeKnowledgeBase,
|
||||
KnowledgeBaseID: "kb-1",
|
||||
}},
|
||||
EmbeddingTopK: 10,
|
||||
},
|
||||
PipelineState: types.PipelineState{RewriteQuery: "精简邮件效果"},
|
||||
}
|
||||
|
||||
err := plugin.OnEvent(context.Background(), types.CHUNK_SEARCH, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
if err == nil || err.ErrorType != ErrSearch.ErrorType {
|
||||
t.Fatalf("expected search_failed, got %#v", err)
|
||||
}
|
||||
if !errors.Is(err.Err, rootCause) {
|
||||
t.Fatalf("expected root cause to be preserved, got %v", err.Err)
|
||||
}
|
||||
}
|
||||
|
||||
type degradingSearchKnowledgeBaseService struct {
|
||||
interfaces.KnowledgeBaseService
|
||||
embedErr error
|
||||
gotParams *types.SearchParams
|
||||
searchResult []*types.SearchResult
|
||||
}
|
||||
|
||||
func (s *degradingSearchKnowledgeBaseService) GetKnowledgeBasesByIDsOnly(
|
||||
context.Context, []string,
|
||||
) ([]*types.KnowledgeBase, error) {
|
||||
return []*types.KnowledgeBase{{
|
||||
ID: "kb-1",
|
||||
Type: types.KnowledgeBaseTypeDocument,
|
||||
EmbeddingModelID: "embedding-1",
|
||||
IndexingStrategy: types.DefaultIndexingStrategy(),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (s *degradingSearchKnowledgeBaseService) ResolveEmbeddingModelKeys(
|
||||
context.Context, []*types.KnowledgeBase,
|
||||
) map[string]string {
|
||||
return map[string]string{"kb-1": "doubao|https://ark.cn-beijing.volces.com/api/v3"}
|
||||
}
|
||||
|
||||
func (s *degradingSearchKnowledgeBaseService) GetQueryEmbedding(
|
||||
context.Context, string, string,
|
||||
) ([]float32, error) {
|
||||
return nil, s.embedErr
|
||||
}
|
||||
|
||||
func (s *degradingSearchKnowledgeBaseService) HybridSearch(
|
||||
_ context.Context, _ string, params types.SearchParams,
|
||||
) ([]*types.SearchResult, error) {
|
||||
s.gotParams = ¶ms
|
||||
return s.searchResult, nil
|
||||
}
|
||||
|
||||
// embedding API 限流/长尾时检索必须降级为纯关键词模式返回结果,
|
||||
// 而不是让整次 knowledge-search 以 500 失败(评测中三次复现的故障)。
|
||||
func TestSearchEmbeddingFailureDegradesToKeywordSearch(t *testing.T) {
|
||||
svc := °radingSearchKnowledgeBaseService{
|
||||
embedErr: errors.New("embedding rate limited"),
|
||||
searchResult: []*types.SearchResult{
|
||||
{ID: "chunk-1", Content: "关键词命中的内容", KnowledgeID: "k-1"},
|
||||
},
|
||||
}
|
||||
plugin := &PluginSearch{knowledgeBaseService: svc}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SearchTargets: types.SearchTargets{{
|
||||
Type: types.SearchTargetTypeKnowledgeBase,
|
||||
KnowledgeBaseID: "kb-1",
|
||||
}},
|
||||
EmbeddingTopK: 10,
|
||||
},
|
||||
PipelineState: types.PipelineState{RewriteQuery: "树状筛选器 新建入口"},
|
||||
}
|
||||
|
||||
err := plugin.OnEvent(context.Background(), types.CHUNK_SEARCH, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected degraded keyword search to succeed, got %#v", err)
|
||||
}
|
||||
if len(chatManage.SearchResult) != 1 || chatManage.SearchResult[0].ID != "chunk-1" {
|
||||
t.Fatalf("expected keyword results to be returned, got %#v", chatManage.SearchResult)
|
||||
}
|
||||
if svc.gotParams == nil || !svc.gotParams.DisableVectorMatch {
|
||||
t.Fatalf("expected DisableVectorMatch=true after embedding failure, got %#v", svc.gotParams)
|
||||
}
|
||||
if len(svc.gotParams.QueryEmbedding) != 0 {
|
||||
t.Fatalf("expected empty query embedding in degraded mode")
|
||||
}
|
||||
}
|
||||
|
||||
type vectorOnlySearchKnowledgeBaseService struct {
|
||||
interfaces.KnowledgeBaseService
|
||||
embedErr error
|
||||
hybridCalled bool
|
||||
}
|
||||
|
||||
func (s *vectorOnlySearchKnowledgeBaseService) GetKnowledgeBasesByIDsOnly(
|
||||
context.Context, []string,
|
||||
) ([]*types.KnowledgeBase, error) {
|
||||
return []*types.KnowledgeBase{{
|
||||
ID: "faq-1",
|
||||
Type: types.KnowledgeBaseTypeFAQ,
|
||||
EmbeddingModelID: "embedding-1",
|
||||
IndexingStrategy: types.IndexingStrategy{VectorEnabled: true},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (s *vectorOnlySearchKnowledgeBaseService) ResolveEmbeddingModelKeys(
|
||||
context.Context, []*types.KnowledgeBase,
|
||||
) map[string]string {
|
||||
return map[string]string{"faq-1": "doubao|https://ark.cn-beijing.volces.com/api/v3"}
|
||||
}
|
||||
|
||||
func (s *vectorOnlySearchKnowledgeBaseService) GetQueryEmbedding(
|
||||
context.Context, string, string,
|
||||
) ([]float32, error) {
|
||||
return nil, s.embedErr
|
||||
}
|
||||
|
||||
func (s *vectorOnlySearchKnowledgeBaseService) HybridSearch(
|
||||
context.Context, string, types.SearchParams,
|
||||
) ([]*types.SearchResult, error) {
|
||||
s.hybridCalled = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestSearchEmbeddingFailureOnVectorOnlyTargetPreservesRootCause(t *testing.T) {
|
||||
rootCause := errors.New("embedding endpoint unavailable")
|
||||
svc := &vectorOnlySearchKnowledgeBaseService{embedErr: rootCause}
|
||||
plugin := &PluginSearch{knowledgeBaseService: svc}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SearchTargets: types.SearchTargets{{
|
||||
Type: types.SearchTargetTypeKnowledgeBase,
|
||||
KnowledgeBaseID: "faq-1",
|
||||
}},
|
||||
EmbeddingTopK: 10,
|
||||
},
|
||||
PipelineState: types.PipelineState{RewriteQuery: "退款规则"},
|
||||
}
|
||||
|
||||
err := plugin.OnEvent(context.Background(), types.CHUNK_SEARCH, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
if err == nil || err.ErrorType != ErrSearch.ErrorType {
|
||||
t.Fatalf("expected search_failed, got %#v", err)
|
||||
}
|
||||
if !errors.Is(err.Err, rootCause) {
|
||||
t.Fatalf("expected root cause to be preserved, got %v", err.Err)
|
||||
}
|
||||
if svc.hybridCalled {
|
||||
t.Fatal("vector-only target must not run a disabled-vector search")
|
||||
}
|
||||
}
|
||||
|
||||
type partialSearchKnowledgeBaseService struct {
|
||||
interfaces.KnowledgeBaseService
|
||||
rootCause error
|
||||
}
|
||||
|
||||
func (s *partialSearchKnowledgeBaseService) GetKnowledgeBasesByIDsOnly(
|
||||
_ context.Context, ids []string,
|
||||
) ([]*types.KnowledgeBase, error) {
|
||||
result := make([]*types.KnowledgeBase, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
result = append(result, &types.KnowledgeBase{
|
||||
ID: id,
|
||||
Type: types.KnowledgeBaseTypeDocument,
|
||||
EmbeddingModelID: "embedding-" + id,
|
||||
IndexingStrategy: types.DefaultIndexingStrategy(),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *partialSearchKnowledgeBaseService) ResolveEmbeddingModelKeys(
|
||||
_ context.Context, kbs []*types.KnowledgeBase,
|
||||
) map[string]string {
|
||||
result := make(map[string]string, len(kbs))
|
||||
for _, kb := range kbs {
|
||||
result[kb.ID] = "model-" + kb.ID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *partialSearchKnowledgeBaseService) GetQueryEmbedding(
|
||||
context.Context, string, string,
|
||||
) ([]float32, error) {
|
||||
return []float32{1}, nil
|
||||
}
|
||||
|
||||
func (s *partialSearchKnowledgeBaseService) HybridSearch(
|
||||
_ context.Context, id string, _ types.SearchParams,
|
||||
) ([]*types.SearchResult, error) {
|
||||
if id == "kb-bad" {
|
||||
return nil, s.rootCause
|
||||
}
|
||||
return []*types.SearchResult{{ID: "chunk-good", Content: "可用结果", KnowledgeID: "knowledge-good"}}, nil
|
||||
}
|
||||
|
||||
type stubWebSearchService struct {
|
||||
interfaces.WebSearchService
|
||||
results []*types.WebSearchResult
|
||||
}
|
||||
|
||||
func (s *stubWebSearchService) Search(
|
||||
_ context.Context, _ string, _ *types.WebSearchConfig, _ string,
|
||||
) ([]*types.WebSearchResult, error) {
|
||||
return s.results, nil
|
||||
}
|
||||
|
||||
func TestSearchKeepsWebResultsWhenKnowledgeBaseFails(t *testing.T) {
|
||||
rootCause := errors.New("kb store unavailable")
|
||||
plugin := &PluginSearch{
|
||||
knowledgeBaseService: &failingSearchKnowledgeBaseService{err: rootCause},
|
||||
webSearchService: &stubWebSearchService{
|
||||
results: []*types.WebSearchResult{{
|
||||
URL: "https://example.com/doc",
|
||||
Title: "Web hit",
|
||||
Content: "web content",
|
||||
}},
|
||||
},
|
||||
tenantService: &struct{ interfaces.TenantService }{},
|
||||
}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SearchTargets: types.SearchTargets{{
|
||||
Type: types.SearchTargetTypeKnowledgeBase,
|
||||
KnowledgeBaseID: "kb-1",
|
||||
}},
|
||||
EmbeddingTopK: 10,
|
||||
WebSearchEnabled: true,
|
||||
WebSearchProviderID: "provider-1",
|
||||
},
|
||||
PipelineState: types.PipelineState{RewriteQuery: "并发检索"},
|
||||
}
|
||||
nextCalled := false
|
||||
|
||||
err := plugin.OnEvent(context.Background(), types.CHUNK_SEARCH, chatManage, func() *PluginError {
|
||||
nextCalled = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected web results to keep pipeline alive, got %#v", err)
|
||||
}
|
||||
if !nextCalled {
|
||||
t.Fatal("expected pipeline to continue with web results")
|
||||
}
|
||||
if len(chatManage.SearchResult) != 1 || chatManage.SearchResult[0].ID != "https://example.com/doc" {
|
||||
t.Fatalf("expected web search result, got %#v", chatManage.SearchResult)
|
||||
}
|
||||
}
|
||||
|
||||
type wikiOnlySearchKnowledgeBaseService struct {
|
||||
interfaces.KnowledgeBaseService
|
||||
embedErr error
|
||||
hybridCalled bool
|
||||
}
|
||||
|
||||
func (s *wikiOnlySearchKnowledgeBaseService) GetKnowledgeBasesByIDsOnly(
|
||||
context.Context, []string,
|
||||
) ([]*types.KnowledgeBase, error) {
|
||||
return []*types.KnowledgeBase{{
|
||||
ID: "wiki-1",
|
||||
Type: types.KnowledgeBaseTypeDocument,
|
||||
EmbeddingModelID: "embedding-1",
|
||||
IndexingStrategy: types.IndexingStrategy{WikiEnabled: true},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (s *wikiOnlySearchKnowledgeBaseService) ResolveEmbeddingModelKeys(
|
||||
context.Context, []*types.KnowledgeBase,
|
||||
) map[string]string {
|
||||
return map[string]string{"wiki-1": "doubao|https://ark.cn-beijing.volces.com/api/v3"}
|
||||
}
|
||||
|
||||
func (s *wikiOnlySearchKnowledgeBaseService) GetQueryEmbedding(
|
||||
context.Context, string, string,
|
||||
) ([]float32, error) {
|
||||
return nil, s.embedErr
|
||||
}
|
||||
|
||||
func (s *wikiOnlySearchKnowledgeBaseService) HybridSearch(
|
||||
context.Context, string, types.SearchParams,
|
||||
) ([]*types.SearchResult, error) {
|
||||
s.hybridCalled = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestSearchEmbeddingFailureOnWikiOnlyTargetReportsNoResults(t *testing.T) {
|
||||
svc := &wikiOnlySearchKnowledgeBaseService{
|
||||
embedErr: errors.New("embedding endpoint unavailable"),
|
||||
}
|
||||
plugin := &PluginSearch{knowledgeBaseService: svc}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SearchTargets: types.SearchTargets{{
|
||||
Type: types.SearchTargetTypeKnowledgeBase,
|
||||
KnowledgeBaseID: "wiki-1",
|
||||
}},
|
||||
EmbeddingTopK: 10,
|
||||
},
|
||||
PipelineState: types.PipelineState{RewriteQuery: "wiki only"},
|
||||
}
|
||||
|
||||
err := plugin.OnEvent(context.Background(), types.CHUNK_SEARCH, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
if err != ErrSearchNothing {
|
||||
t.Fatalf("expected search_nothing for wiki-only KB, got %#v", err)
|
||||
}
|
||||
if !svc.hybridCalled {
|
||||
t.Fatal("wiki-only target should still delegate empty recall to HybridSearch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchKeepsSuccessfulResultsWhenAnotherTargetFails(t *testing.T) {
|
||||
rootCause := errors.New("one store unavailable")
|
||||
plugin := &PluginSearch{knowledgeBaseService: &partialSearchKnowledgeBaseService{rootCause: rootCause}}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SearchTargets: types.SearchTargets{
|
||||
{Type: types.SearchTargetTypeKnowledgeBase, KnowledgeBaseID: "kb-good"},
|
||||
{Type: types.SearchTargetTypeKnowledgeBase, KnowledgeBaseID: "kb-bad"},
|
||||
},
|
||||
EmbeddingTopK: 10,
|
||||
},
|
||||
PipelineState: types.PipelineState{RewriteQuery: "部分成功"},
|
||||
}
|
||||
nextCalled := false
|
||||
|
||||
err := plugin.OnEvent(context.Background(), types.CHUNK_SEARCH, chatManage, func() *PluginError {
|
||||
nextCalled = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected partial success to continue, got %#v", err)
|
||||
}
|
||||
if !nextCalled {
|
||||
t.Fatal("expected pipeline to continue with successful results")
|
||||
}
|
||||
if len(chatManage.SearchResult) != 1 || chatManage.SearchResult[0].ID != "chunk-good" {
|
||||
t.Fatalf("expected successful target result, got %#v", chatManage.SearchResult)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginSearchParallel implements parallel search functionality combining chunk search and entity search
|
||||
type PluginSearchParallel struct {
|
||||
// Chunk search dependencies
|
||||
knowledgeBaseService interfaces.KnowledgeBaseService
|
||||
knowledgeService interfaces.KnowledgeService
|
||||
config *config.Config
|
||||
webSearchService interfaces.WebSearchService
|
||||
tenantService interfaces.TenantService
|
||||
sessionService interfaces.SessionService
|
||||
|
||||
// Entity search dependencies
|
||||
graphRepo interfaces.RetrieveGraphRepository
|
||||
chunkRepo interfaces.ChunkRepository
|
||||
knowledgeRepo interfaces.KnowledgeRepository
|
||||
|
||||
// Internal plugins
|
||||
searchPlugin *PluginSearch
|
||||
searchEntityPlugin *PluginSearchEntity
|
||||
}
|
||||
|
||||
// NewPluginSearchParallel creates a new parallel search plugin
|
||||
func NewPluginSearchParallel(
|
||||
eventManager *EventManager,
|
||||
knowledgeBaseService interfaces.KnowledgeBaseService,
|
||||
knowledgeService interfaces.KnowledgeService,
|
||||
chunkService interfaces.ChunkService,
|
||||
config *config.Config,
|
||||
webSearchService interfaces.WebSearchService,
|
||||
tenantService interfaces.TenantService,
|
||||
sessionService interfaces.SessionService,
|
||||
webSearchStateService interfaces.WebSearchStateService,
|
||||
webSearchProviderRepo interfaces.WebSearchProviderRepository,
|
||||
graphRepository interfaces.RetrieveGraphRepository,
|
||||
chunkRepository interfaces.ChunkRepository,
|
||||
knowledgeRepository interfaces.KnowledgeRepository,
|
||||
) *PluginSearchParallel {
|
||||
// Create internal plugins without registering them
|
||||
searchPlugin := &PluginSearch{
|
||||
knowledgeBaseService: knowledgeBaseService,
|
||||
knowledgeService: knowledgeService,
|
||||
chunkService: chunkService,
|
||||
config: config,
|
||||
webSearchService: webSearchService,
|
||||
tenantService: tenantService,
|
||||
sessionService: sessionService,
|
||||
webSearchStateService: webSearchStateService,
|
||||
webSearchProviderRepo: webSearchProviderRepo,
|
||||
}
|
||||
|
||||
searchEntityPlugin := &PluginSearchEntity{
|
||||
graphRepo: graphRepository,
|
||||
chunkRepo: chunkRepository,
|
||||
knowledgeRepo: knowledgeRepository,
|
||||
}
|
||||
|
||||
res := &PluginSearchParallel{
|
||||
knowledgeBaseService: knowledgeBaseService,
|
||||
knowledgeService: knowledgeService,
|
||||
config: config,
|
||||
webSearchService: webSearchService,
|
||||
tenantService: tenantService,
|
||||
sessionService: sessionService,
|
||||
graphRepo: graphRepository,
|
||||
chunkRepo: chunkRepository,
|
||||
knowledgeRepo: knowledgeRepository,
|
||||
searchPlugin: searchPlugin,
|
||||
searchEntityPlugin: searchEntityPlugin,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginSearchParallel) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHUNK_SEARCH_PARALLEL}
|
||||
}
|
||||
|
||||
// OnEvent handles parallel search events - runs chunk search and entity search concurrently
|
||||
func (p *PluginSearchParallel) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
// Intent-based skip: query-understand step determined KB retrieval is unnecessary
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
pipelineInfo(ctx, "SearchParallel", "skip", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"reason": "intent_no_search",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "SearchParallel", "start", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"has_entities": len(chatManage.Entity) > 0,
|
||||
"rewrite_query": chatManage.RewriteQuery,
|
||||
})
|
||||
|
||||
// Deep-copy to avoid concurrent read/write on shared slice fields
|
||||
chunkCM := chatManage.Clone()
|
||||
chunkCM.SearchResult = nil
|
||||
entityCM := chatManage.Clone()
|
||||
entityCM.SearchResult = nil
|
||||
|
||||
noop := func() *PluginError { return nil }
|
||||
|
||||
tasks := []ParallelTask{
|
||||
{
|
||||
Name: "chunk_search",
|
||||
Run: func() *PluginError {
|
||||
err := p.searchPlugin.OnEvent(ctx, types.CHUNK_SEARCH, chunkCM, noop)
|
||||
pipelineInfo(ctx, "SearchParallel", "chunk_search_done", map[string]interface{}{
|
||||
"result_count": len(chunkCM.SearchResult),
|
||||
"has_error": err != nil && err != ErrSearchNothing,
|
||||
})
|
||||
if err == ErrSearchNothing {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "entity_search",
|
||||
Run: func() *PluginError {
|
||||
if len(chatManage.Entity) == 0 {
|
||||
pipelineInfo(ctx, "SearchParallel", "entity_search_skip", map[string]interface{}{
|
||||
"reason": "no_entities",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
err := p.searchEntityPlugin.OnEvent(ctx, types.ENTITY_SEARCH, entityCM, noop)
|
||||
pipelineInfo(ctx, "SearchParallel", "entity_search_done", map[string]interface{}{
|
||||
"result_count": len(entityCM.SearchResult),
|
||||
"has_error": err != nil && err != ErrSearchNothing,
|
||||
})
|
||||
if err == ErrSearchNothing {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
errs := RunParallel(tasks...)
|
||||
|
||||
// Merge results from both searches
|
||||
chatManage.SearchResult = append(chunkCM.SearchResult, entityCM.SearchResult...)
|
||||
chatManage.SearchResult = removeDuplicateResults(chatManage.SearchResult)
|
||||
|
||||
for name, err := range errs {
|
||||
logger.Warnf(ctx, "[SearchParallel] %s error: %v", name, err.Err)
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "SearchParallel", "complete", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"chunk_results": len(chunkCM.SearchResult),
|
||||
"entity_results": len(entityCM.SearchResult),
|
||||
"total_results": len(chatManage.SearchResult),
|
||||
"error_count": len(errs),
|
||||
})
|
||||
|
||||
if len(chatManage.SearchResult) == 0 {
|
||||
if err, ok := errs["chunk_search"]; ok {
|
||||
return err
|
||||
}
|
||||
return ErrSearchNothing
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"source_sha256": "ecbfc3d600d9990e67d0b5f573f240447977f73d5dca191d61b2c5513e8c3e07",
|
||||
"source_content": "〖 使用范围 〗 用于阴道黏膜抑菌 〔 注意事项 〗 1. 不得用于治疗疾病或者改善症状 ;\n2 , 不得口服 , 置于丿 L 童不易触及处 ;\n3 · 避免与拮抗药物同用 ;\n5 , 禁止用于破损皮肤黏膜 ;\n\n4 , 过敏者慎用 ;\n6 . 避开女性三期使用 ( 经期 、 孕期 、 产期 ) 〔 储存方法 〗 储存于阴凉干燥处 , 避光 、 隔热 、 密封保存 。\n〖 警示说明 〗 本品不是药品 , 不具有治疗 、 护理 、 保健作用 ;\n不应用于性生活中对性病的预防\n\n",
|
||||
"graph": {
|
||||
"node": [
|
||||
{
|
||||
"name": "本品",
|
||||
"attributes": [
|
||||
"使用范围:用于阴道黏膜抑菌",
|
||||
"警示说明:本品不是药品,不具有治疗、护理、保健作用",
|
||||
"储存方法:储存于阴凉干燥处,避光、隔热、密封保存"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "治疗疾病或者改善症状"
|
||||
},
|
||||
{
|
||||
"name": "口服"
|
||||
},
|
||||
{
|
||||
"name": "拮抗药物"
|
||||
},
|
||||
{
|
||||
"name": "破损皮肤黏膜"
|
||||
},
|
||||
{
|
||||
"name": "过敏者"
|
||||
},
|
||||
{
|
||||
"name": "女性三期(经期、孕期、产期)"
|
||||
},
|
||||
{
|
||||
"name": "性生活中对性病的预防"
|
||||
},
|
||||
{
|
||||
"name": "阴道黏膜抑菌"
|
||||
},
|
||||
{
|
||||
"name": "避免与拮抗药物同用"
|
||||
},
|
||||
{
|
||||
"name": "女性三期使用(经期、孕期、产期)"
|
||||
},
|
||||
{
|
||||
"name": "储存于阴凉干燥处,避光、隔热、密封保存"
|
||||
},
|
||||
{
|
||||
"name": "治疗、护理、保健作用"
|
||||
}
|
||||
],
|
||||
"relation": [
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "阴道黏膜抑菌",
|
||||
"type": "适用于",
|
||||
"evidence": "用于阴道黏膜抑菌"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "治疗疾病或者改善症状",
|
||||
"type": "不适用于",
|
||||
"evidence": "不得用于治疗疾病或者改善症状"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "口服",
|
||||
"type": "禁用于",
|
||||
"evidence": "不得口服"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "避免与拮抗药物同用",
|
||||
"type": "约束",
|
||||
"evidence": "避免与拮抗药物同用"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "破损皮肤黏膜",
|
||||
"type": "禁用于",
|
||||
"evidence": "禁止用于破损皮肤黏膜"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "过敏者",
|
||||
"type": "慎用于",
|
||||
"evidence": "过敏者慎用"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "女性三期使用(经期、孕期、产期)",
|
||||
"type": "禁用于",
|
||||
"evidence": "避开女性三期使用 ( 经期 、 孕期 、 产期 )"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "储存于阴凉干燥处,避光、隔热、密封保存",
|
||||
"type": "约束",
|
||||
"evidence": "储存于阴凉干燥处 , 避光 、 隔热 、 密封保存"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "治疗、护理、保健作用",
|
||||
"type": "不支持",
|
||||
"evidence": "不具有治疗 、 护理 、 保健作用"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "性生活中对性病的预防",
|
||||
"type": "不适用于",
|
||||
"evidence": "不应用于性生活中对性病的预防"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/infrastructure/web_fetch"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// PluginWebFetch fetches full page content for reranked web search results.
|
||||
// It runs between CHUNK_RERANK and CHUNK_MERGE, replacing snippet content
|
||||
// with the full page text for the top N web results.
|
||||
type PluginWebFetch struct{}
|
||||
|
||||
// NewPluginWebFetch creates and registers a new PluginWebFetch instance
|
||||
func NewPluginWebFetch(eventManager *EventManager) *PluginWebFetch {
|
||||
res := &PluginWebFetch{}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginWebFetch) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.WEB_FETCH}
|
||||
}
|
||||
|
||||
// OnEvent handles the WEB_FETCH event
|
||||
func (p *PluginWebFetch) OnEvent(
|
||||
ctx context.Context,
|
||||
eventType types.EventType,
|
||||
chatManage *types.ChatManage,
|
||||
next func() *PluginError,
|
||||
) *PluginError {
|
||||
if !chatManage.WebFetchEnabled || !chatManage.WebSearchEnabled {
|
||||
pipelineInfo(ctx, "WebFetch", "skip", map[string]any{"reason": "disabled"})
|
||||
return next()
|
||||
}
|
||||
|
||||
topN := chatManage.WebFetchTopN
|
||||
if topN <= 0 {
|
||||
topN = 3
|
||||
}
|
||||
|
||||
// Find web search results in reranked results
|
||||
var webResults []*types.SearchResult
|
||||
for _, r := range chatManage.RerankResult {
|
||||
if strings.ToLower(r.KnowledgeSource) == "web_search" {
|
||||
webResults = append(webResults, r)
|
||||
if len(webResults) >= topN {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(webResults) == 0 {
|
||||
pipelineInfo(ctx, "WebFetch", "skip", map[string]any{"reason": "no_web_results"})
|
||||
return next()
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "[PIPELINE] stage=WebFetch action=start count=%d", len(webResults))
|
||||
|
||||
// Fetch in parallel
|
||||
type fetchResult struct {
|
||||
idx int
|
||||
content string
|
||||
err error
|
||||
}
|
||||
results := make([]fetchResult, len(webResults))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, r := range webResults {
|
||||
wg.Add(1)
|
||||
go func(idx int, sr *types.SearchResult) {
|
||||
defer wg.Done()
|
||||
fetchURL := sr.ID // web search results use URL as ID
|
||||
if fetchURL == "" {
|
||||
return
|
||||
}
|
||||
content, err := web_fetch.FetchURLContent(ctx, fetchURL)
|
||||
results[idx] = fetchResult{idx: idx, content: content, err: err}
|
||||
}(i, r)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Replace snippet content with fetched full content
|
||||
fetchedCount := 0
|
||||
for _, fr := range results {
|
||||
if fr.err != nil {
|
||||
logger.Warnf(ctx, "[PIPELINE] stage=WebFetch action=fetch_failed url=%s err=%v",
|
||||
webResults[fr.idx].ID, fr.err)
|
||||
continue
|
||||
}
|
||||
if fr.content == "" {
|
||||
continue
|
||||
}
|
||||
// Truncate to reasonable size for LLM context
|
||||
content := fr.content
|
||||
if len(content) > 8000 {
|
||||
content = content[:8000] + "\n...(truncated)"
|
||||
}
|
||||
webResults[fr.idx].Content = content
|
||||
fetchedCount++
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "WebFetch", "complete", map[string]any{
|
||||
"fetched": fetchedCount,
|
||||
"total": len(webResults),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// wikiBoostFactor is the score multiplier applied to wiki page chunks.
|
||||
// Wiki pages contain LLM-synthesized, cross-referenced knowledge and should
|
||||
// be preferred over raw document chunks when both are available.
|
||||
const wikiBoostFactor = 1.3
|
||||
|
||||
// PluginWikiBoost boosts the relevance score of wiki page chunks in search results.
|
||||
// Wiki pages contain pre-synthesized knowledge that is more coherent and
|
||||
// cross-referenced than raw document chunks, so they should rank higher.
|
||||
//
|
||||
// This plugin runs in the CHUNK_RERANK phase, after initial retrieval and reranking.
|
||||
// It identifies chunks with ChunkType == "wiki_page" and multiplies their score.
|
||||
type PluginWikiBoost struct {
|
||||
kbService interfaces.KnowledgeBaseService
|
||||
}
|
||||
|
||||
// NewPluginWikiBoost creates and registers the wiki boost plugin
|
||||
func NewPluginWikiBoost(eventManager *EventManager, kbService interfaces.KnowledgeBaseService) *PluginWikiBoost {
|
||||
p := &PluginWikiBoost{
|
||||
kbService: kbService,
|
||||
}
|
||||
eventManager.Register(p)
|
||||
return p
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginWikiBoost) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHUNK_RERANK}
|
||||
}
|
||||
|
||||
// OnEvent boosts wiki page chunk scores after reranking
|
||||
func (p *PluginWikiBoost) OnEvent(
|
||||
ctx context.Context,
|
||||
eventType types.EventType,
|
||||
chatManage *types.ChatManage,
|
||||
next func() *PluginError,
|
||||
) *PluginError {
|
||||
// Run the normal reranking first
|
||||
if err := next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fast path: skip all work if there are no wiki_page chunks in the result set.
|
||||
// This avoids hitting the KB service on every chat turn for non-wiki queries.
|
||||
hasWikiChunk := false
|
||||
for i := range chatManage.RerankResult {
|
||||
if chatManage.RerankResult[i].ChunkType == types.ChunkTypeWikiPage {
|
||||
hasWikiChunk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWikiChunk {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirm at least one search target is actually a wiki KB.
|
||||
hasWikiKB := false
|
||||
for _, target := range chatManage.SearchTargets {
|
||||
if target == nil || target.KnowledgeBaseID == "" {
|
||||
continue
|
||||
}
|
||||
kb, err := p.kbService.GetKnowledgeBaseByIDOnly(ctx, target.KnowledgeBaseID)
|
||||
if err == nil && kb != nil && kb.IsWikiEnabled() {
|
||||
hasWikiKB = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWikiKB {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Boost wiki page chunks in RerankResult
|
||||
boostedCount := 0
|
||||
for i := range chatManage.RerankResult {
|
||||
if chatManage.RerankResult[i].ChunkType == types.ChunkTypeWikiPage {
|
||||
chatManage.RerankResult[i].Score *= wikiBoostFactor
|
||||
boostedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if boostedCount > 0 {
|
||||
logger.Infof(ctx, "WikiBoost: boosted %d wiki page chunks by %.1fx", boostedCount, wikiBoostFactor)
|
||||
// Re-sort by score after boosting; stable sort preserves ordering for ties.
|
||||
sort.SliceStable(chatManage.RerankResult, func(i, j int) bool {
|
||||
return chatManage.RerankResult[i].Score > chatManage.RerankResult[j].Score
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user