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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
@@ -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 ![alt](url). 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![流程图](resource://AbCdEfGhIjKlMnOpQrStUv)",
)
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)), &sections) != 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...)
}
@@ -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)
}
}
@@ -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 &lt;title&gt;") {
t.Fatalf("expected escaped title, got %q", header)
}
if !strings.Contains(header, "&lt;/metadata&gt;&lt;chunk&gt;") {
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![one](u1)\n\nparent body\n\n![two](u2)",
}
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![matched](u1)", StartAt: 900, EndAt: 910,
},
"parent": {
ID: "parent", ChunkType: types.ChunkTypeParentText,
Content: "grandparent context before\n\n![matched](u1)\n\ngrandparent context after\n\n![sibling](u2)",
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: "![p2](u2)",
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 := "![p1](u1)\n\n![p2](u2)\n\n![p3](u3)"
textStart := len([]rune("![p1](u1)\n\n"))
textEnd := textStart + len([]rune("![p2](u2)"))
sliced := searchutil.SliceContentByDocumentRange(parentContent, 0, textStart, textEnd)
if sliced != "![p2](u2)" {
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 ![alt](url) — the entire construct is noise.
// URL group supports one level of balanced parentheses.
reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\([^()\s]*(?:\([^)]*\)[^()\s]*)*\)`)
// reLinkedImage matches [![alt](img_url)](link_url) — unwrap to ![alt](img_url)
// 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 [![alt](img_url)](link_url) → ![alt](img_url)
// so that the next step removes the full construct cleanly.
text = reLinkedImage.ReplaceAllString(text, "![$1]($2)")
// 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: "前文 ![图片说明](https://example.com/img.png) 后文",
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)。
![产品截图](images/product.png)
> 用户评价:非常好用
- 功能一
- 功能二
` + "```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 = &params
return s.searchResult, nil
}
// embedding API 限流/长尾时检索必须降级为纯关键词模式返回结果,
// 而不是让整次 knowledge-search 以 500 失败(评测中三次复现的故障)。
func TestSearchEmbeddingFailureDegradesToKeywordSearch(t *testing.T) {
svc := &degradingSearchKnowledgeBaseService{
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()
}
@@ -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
}