WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/accesspolicy"
|
||||
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// PrepareAgentExecutionContext delegates only live bindings owned by the
|
||||
// authorized Agent's creator. The caller's publication snapshot stays intact.
|
||||
func (s *customAgentService) PrepareAgentExecutionContext(ctx context.Context, id string) (context.Context, error) {
|
||||
prepared, err := s.prepareAgentExecution(ctx, id, map[string]bool{}, map[string]bool{})
|
||||
if errors.Is(err, ErrAgentNotFound) {
|
||||
return ctx, apperrors.NewNotFoundError("Agent 已删除或无权使用")
|
||||
}
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func (s *customAgentService) prepareAgentExecution(ctx context.Context, id string, visiting, visited map[string]bool) (context.Context, error) {
|
||||
if visiting[id] {
|
||||
return ctx, apperrors.NewBadRequestError("Agent 依赖存在循环或层级过多,请调整融合配置")
|
||||
}
|
||||
if visited[id] {
|
||||
return ctx, nil
|
||||
}
|
||||
if len(visited) >= 64 {
|
||||
return ctx, apperrors.NewBadRequestError("Agent 依赖存在循环或层级过多,请调整融合配置")
|
||||
}
|
||||
agent, err := s.GetAgentByID(ctx, id)
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
if agent == nil || !agent.Enabled || !accesspolicy.Allowed(ctx, "agents.read") {
|
||||
return ctx, apperrors.NewForbiddenError("Agent 已停用或无权使用")
|
||||
}
|
||||
if guard, ok := s.repo.(interface {
|
||||
CheckDigitalHumanAgentExecution(context.Context, *types.CustomAgent) error
|
||||
}); ok {
|
||||
if err := guard.CheckDigitalHumanAgentExecution(ctx, agent); err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
}
|
||||
visiting[id] = true
|
||||
defer delete(visiting, id)
|
||||
visited[id] = true
|
||||
if agent.Config.KBSelectionMode == "selected" && len(agent.Config.KnowledgeBases) > 0 && s.kbService != nil {
|
||||
for _, kbID := range agent.Config.KnowledgeBases {
|
||||
kb, err := s.kbService.GetRepository().GetKnowledgeBaseByIDAndTenant(ctx, kbID, agent.TenantID)
|
||||
if err != nil || kb == nil {
|
||||
continue
|
||||
}
|
||||
if kb.CreatorID != "" && kb.CreatorID == agent.CreatedBy && kb.VersionState == "" {
|
||||
ctx = accesspolicy.WithExecutionDependency(ctx, agent.TenantID, "knowledge", kb.ID, kb.CreatorID)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, fusionID := range agent.Config.FusionAgentIDs {
|
||||
child, err := s.repo.GetAgentByID(ctx, fusionID, agent.TenantID)
|
||||
if err != nil || child == nil {
|
||||
return ctx, apperrors.NewForbiddenError("融合 Agent 已删除或不可用")
|
||||
}
|
||||
if child.CreatedBy != "" && child.CreatedBy == agent.CreatedBy {
|
||||
ctx = accesspolicy.WithExecutionDependency(ctx, agent.TenantID, "agents", child.ID, child.CreatedBy)
|
||||
}
|
||||
ctx, err = s.prepareAgentExecution(ctx, fusionID, visiting, visited)
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
}
|
||||
return ctx, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/accesspolicy"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func agentExecutionHarness(t *testing.T) (*customAgentService, *lifecycleAgentRepo, *knowledgeBaseService, context.Context) {
|
||||
t.Helper()
|
||||
repo := &lifecycleAgentRepo{rows: map[string]*types.CustomAgent{
|
||||
"root": {ID: "root", TenantID: 1, CreatedBy: "alice", Visibility: "private", Enabled: true, Config: types.CustomAgentConfig{KBSelectionMode: "selected", KnowledgeBases: []string{"owned", "unrelated"}, FusionAgentIDs: []string{"child"}}},
|
||||
"child": {ID: "child", TenantID: 1, CreatedBy: "alice", Visibility: "private", Enabled: true, Config: types.CustomAgentConfig{KBSelectionMode: "selected", KnowledgeBases: []string{"nested"}}},
|
||||
}}
|
||||
kbs := newFakeKBRepo()
|
||||
for _, id := range []string{"owned", "nested", "unbound"} {
|
||||
kbs.rows[id] = &types.KnowledgeBase{ID: id, TenantID: 1, CreatorID: "alice"}
|
||||
}
|
||||
kbs.rows["unrelated"] = &types.KnowledgeBase{ID: "unrelated", TenantID: 1, CreatorID: "carol"}
|
||||
kbService := newPR3KBService(kbs, &fakeRegistry{}, &fakeOwnership{})
|
||||
ctx := accesspolicy.WithSnapshot(instructionContext(1, "bob", types.TenantRoleContributor), &accesspolicy.Snapshot{Restricted: true, Permissions: map[string]bool{"agents.read": true, "knowledge.read": true}, Grants: map[string]bool{"agents:root": true}})
|
||||
return &customAgentService{repo: repo, kbService: kbService}, repo, kbService, ctx
|
||||
}
|
||||
|
||||
func TestAgentExecutionDelegatesOnlyLiveOwnedBindings(t *testing.T) {
|
||||
s, _, kbs, ctx := agentExecutionHarness(t)
|
||||
run, err := s.PrepareAgentExecutionContext(ctx, "root")
|
||||
require.NoError(t, err)
|
||||
for _, id := range []string{"owned", "nested"} {
|
||||
_, err = kbs.GetKnowledgeBaseByID(run, id)
|
||||
require.NoError(t, err)
|
||||
_, err = kbs.GetKnowledgeBaseByID(ctx, id)
|
||||
require.Error(t, err, "execution must not mutate request grants")
|
||||
}
|
||||
for _, id := range []string{"unrelated", "unbound"} {
|
||||
_, err = kbs.GetKnowledgeBaseByID(run, id)
|
||||
require.Error(t, err)
|
||||
}
|
||||
_, err = s.GetAgentByID(run, "child")
|
||||
require.NoError(t, err)
|
||||
_, err = s.GetAgentByID(ctx, "child")
|
||||
require.Error(t, err)
|
||||
_, err = s.CopyAgent(run, "child")
|
||||
require.Error(t, err, "runtime dependency must not grant copy or mutation")
|
||||
listed, err := s.ListAgents(run)
|
||||
require.NoError(t, err)
|
||||
for _, a := range listed {
|
||||
require.NotEqual(t, "child", a.ID)
|
||||
}
|
||||
list := filterReadableKnowledgeBases(run, []*types.KnowledgeBase{{ID: "owned", TenantID: 1, CreatorID: "alice"}})
|
||||
require.Empty(t, list, "runtime dependency must not broaden resource list visibility")
|
||||
}
|
||||
|
||||
func TestAgentExecutionRechecksRevocationChangesAndCycles(t *testing.T) {
|
||||
for _, test := range []string{"revoke-root", "disable-child", "different-owner", "cycle", "deny-agents"} {
|
||||
t.Run(test, func(t *testing.T) {
|
||||
s, repo, _, ctx := agentExecutionHarness(t)
|
||||
switch test {
|
||||
case "revoke-root":
|
||||
accesspolicy.FromContext(ctx).Grants = nil
|
||||
case "disable-child":
|
||||
repo.rows["child"].Enabled = false
|
||||
case "different-owner":
|
||||
repo.rows["child"].CreatedBy = "carol"
|
||||
case "cycle":
|
||||
repo.rows["child"].Config.FusionAgentIDs = []string{"root"}
|
||||
case "deny-agents":
|
||||
accesspolicy.FromContext(ctx).Permissions["agents.read"] = false
|
||||
}
|
||||
_, err := s.PrepareAgentExecutionContext(ctx, "root")
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
s, repo, kbs, ctx := agentExecutionHarness(t)
|
||||
repo.rows["root"].Config.KnowledgeBases = nil
|
||||
run, err := s.PrepareAgentExecutionContext(ctx, "root")
|
||||
require.NoError(t, err)
|
||||
_, err = kbs.GetKnowledgeBaseByID(run, "owned")
|
||||
require.Error(t, err, "removed binding cannot be recreated from saved snapshots")
|
||||
accesspolicy.FromContext(ctx).Permissions["knowledge.read"] = false
|
||||
_, err = kbs.GetKnowledgeBaseByID(run, "nested")
|
||||
require.Error(t, err, "module permission still gates runtime use")
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func (s *customAgentService) validateFusionAgents(ctx context.Context, agent *types.CustomAgent) error {
|
||||
if err := agent.Config.ValidateFusion(agent.ID); err != nil {
|
||||
return apperrors.NewBadRequestError(err.Error())
|
||||
}
|
||||
for _, id := range agent.Config.FusionAgentIDs {
|
||||
target, err := s.GetAgentByID(ctx, id)
|
||||
if err != nil || target == nil || target.TenantID != agent.TenantID || !target.Enabled {
|
||||
return apperrors.NewBadRequestError("融合 Agent 不存在、已停用或无权使用")
|
||||
}
|
||||
if target.Config.Role != types.AgentRoleProduction {
|
||||
return apperrors.NewBadRequestError("只能融合内容生产 Agent")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewAgentDefaultsToPrivateWithoutPublishingToOtherUsers(t *testing.T) {
|
||||
s := &customAgentService{repo: &lifecycleAgentRepo{rows: map[string]*types.CustomAgent{}}}
|
||||
agent, err := s.CreateAgent(instructionContext(1, "alice", types.TenantRoleContributor), &types.CustomAgent{Name: "Private creation"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "private", agent.Visibility)
|
||||
require.True(t, agent.Enabled)
|
||||
_, err = s.GetAgentByID(instructionContext(1, "bob", types.TenantRoleContributor), agent.ID)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFusionValidationRequiresAuthorizedEnabledProductionAgents(t *testing.T) {
|
||||
ctx := instructionContext(1, "bob", types.TenantRoleContributor)
|
||||
primary := &types.CustomAgent{ID: "primary", TenantID: 1, Config: types.CustomAgentConfig{Role: types.AgentRoleProduction, FusionAgentIDs: []string{"aux"}}}
|
||||
aux := &types.CustomAgent{ID: "aux", TenantID: 1, CreatedBy: "alice", Name: "Secret", Visibility: "private", Enabled: true, Config: types.CustomAgentConfig{Role: types.AgentRoleProduction}}
|
||||
s := &customAgentService{repo: &lifecycleAgentRepo{rows: map[string]*types.CustomAgent{"aux": aux}}}
|
||||
err := s.validateFusionAgents(ctx, primary)
|
||||
require.Error(t, err)
|
||||
require.NotContains(t, err.Error(), "Secret")
|
||||
aux.Visibility = "global"
|
||||
require.NoError(t, s.validateFusionAgents(ctx, primary))
|
||||
aux.Enabled = false
|
||||
require.Error(t, s.validateFusionAgents(ctx, primary))
|
||||
aux.Enabled = true
|
||||
aux.Config.Role = types.AgentRoleQC
|
||||
require.Error(t, s.validateFusionAgents(ctx, primary))
|
||||
aux.Config.Role = types.AgentRoleProduction
|
||||
aux.TenantID = 2
|
||||
require.Error(t, s.validateFusionAgents(ctx, primary))
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agenttools "github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// agentHistoryFetchMultiplier controls how many raw DB messages to fetch
|
||||
// when assembling history. Each turn contributes ~2 rows (user + assistant);
|
||||
// we ask for a generous multiple so we never under-fetch when some pairs are
|
||||
// incomplete (e.g. an in-flight turn).
|
||||
const agentHistoryFetchMultiplier = 4
|
||||
|
||||
// agentHistoryFetchMin is the floor for the DB fetch limit, used when
|
||||
// maxRounds is small or unset.
|
||||
const agentHistoryFetchMin = 50
|
||||
|
||||
var agentHistoryThinkTagRegex = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
||||
|
||||
// LoadAgentHistory rebuilds the multi-turn LLM context for an Agent-mode
|
||||
// session directly from the persistent messages table. The result is a
|
||||
// chronologically ordered list of chat.Message entries suitable for prepending
|
||||
// to the current turn (without system prompt; the engine adds that itself).
|
||||
//
|
||||
// For each historical turn it emits:
|
||||
// 1. A user message (RenderedContent if present, else Content, plus any
|
||||
// image captions appended).
|
||||
// 2. For each AgentStep with non-terminal tool calls (i.e. excluding
|
||||
// final_answer), an assistant message carrying the step's thought and
|
||||
// tool_calls, followed by one tool message per tool result.
|
||||
// 3. A final assistant message with the canonical answer (msg.Content with
|
||||
// <think> blocks stripped).
|
||||
//
|
||||
// Turns lacking either user or assistant content are skipped. The newest
|
||||
// maxRounds turns are returned in chronological order.
|
||||
//
|
||||
// DB is treated as the single source of truth — there is no Redis/in-memory
|
||||
// cache layer above this function. Callers are expected to invoke it once
|
||||
// per turn before handing the messages to the agent engine.
|
||||
func LoadAgentHistory(
|
||||
ctx context.Context,
|
||||
messageRepo interfaces.MessageRepository,
|
||||
sessionID string,
|
||||
maxRounds int,
|
||||
) ([]chat.Message, error) {
|
||||
if maxRounds <= 0 {
|
||||
return []chat.Message{}, nil
|
||||
}
|
||||
|
||||
fetchLimit := maxRounds * agentHistoryFetchMultiplier
|
||||
if fetchLimit < agentHistoryFetchMin {
|
||||
fetchLimit = agentHistoryFetchMin
|
||||
}
|
||||
|
||||
rows, err := messageRepo.GetRecentMessagesBySession(ctx, sessionID, fetchLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load agent history: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []chat.Message{}, nil
|
||||
}
|
||||
|
||||
type pair struct {
|
||||
user *types.Message
|
||||
assistant *types.Message
|
||||
createdAt time.Time
|
||||
}
|
||||
pairs := make(map[string]*pair)
|
||||
for _, msg := range rows {
|
||||
p, ok := pairs[msg.RequestID]
|
||||
if !ok {
|
||||
p = &pair{}
|
||||
pairs[msg.RequestID] = p
|
||||
}
|
||||
switch msg.Role {
|
||||
case "user":
|
||||
p.user = msg
|
||||
if p.createdAt.IsZero() || msg.CreatedAt.Before(p.createdAt) {
|
||||
p.createdAt = msg.CreatedAt
|
||||
}
|
||||
case "assistant":
|
||||
p.assistant = msg
|
||||
}
|
||||
}
|
||||
|
||||
completePairs := make([]*pair, 0, len(pairs))
|
||||
for _, p := range pairs {
|
||||
if p.user != nil && p.assistant != nil && p.assistant.IsCompleted {
|
||||
completePairs = append(completePairs, p)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(completePairs, func(i, j int) bool {
|
||||
return completePairs[i].createdAt.Before(completePairs[j].createdAt)
|
||||
})
|
||||
|
||||
if len(completePairs) > maxRounds {
|
||||
completePairs = completePairs[len(completePairs)-maxRounds:]
|
||||
}
|
||||
|
||||
out := make([]chat.Message, 0, len(completePairs)*4)
|
||||
for _, p := range completePairs {
|
||||
out = append(out, buildUserHistoryMessage(p.user))
|
||||
out = append(out, buildAssistantHistoryMessages(p.assistant)...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildUserHistoryMessage converts a stored user message into the chat.Message
|
||||
// form that should appear in LLM history. It deliberately ignores
|
||||
// RenderedContent: that field is a snapshot of the old prompt and retrieval
|
||||
// context format, which must not be mixed into the current request protocol.
|
||||
// Image captions and attachments are reconstructed from their canonical DB
|
||||
// columns so useful user-provided context is retained without stale RAG data.
|
||||
func buildUserHistoryMessage(m *types.Message) chat.Message {
|
||||
content := m.Content
|
||||
if captions := extractImageCaptionsFromMessage(m.Images); captions != "" {
|
||||
content += "\n\n[用户上传图片内容]\n" + captions
|
||||
}
|
||||
if len(m.Attachments) > 0 {
|
||||
content += m.Attachments.BuildPrompt()
|
||||
}
|
||||
return chat.Message{Role: "user", Content: content}
|
||||
}
|
||||
|
||||
// buildAssistantHistoryMessages reconstructs the assistant side of one
|
||||
// historical turn. It walks AgentSteps to expand intermediate tool calls into
|
||||
// proper OpenAI-shaped assistant + tool messages, then emits the canonical
|
||||
// final answer as a trailing assistant message.
|
||||
//
|
||||
// AgentSteps from KnowledgeQA-mode turns are empty, in which case the result
|
||||
// is just the single final-answer assistant message — exactly mirroring how
|
||||
// the KnowledgeQA pipeline replays history today.
|
||||
func buildAssistantHistoryMessages(m *types.Message) []chat.Message {
|
||||
msgs := make([]chat.Message, 0, len(m.AgentSteps)*2+1)
|
||||
for _, step := range m.AgentSteps {
|
||||
nonTerminalCalls := filterNonTerminalToolCalls(step.ToolCalls)
|
||||
if len(nonTerminalCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
assistantMsg := chat.Message{
|
||||
Role: "assistant",
|
||||
Content: step.Thought,
|
||||
ReasoningContent: step.ReasoningContent,
|
||||
ToolCalls: make([]chat.ToolCall, 0, len(nonTerminalCalls)),
|
||||
}
|
||||
for _, tc := range nonTerminalCalls {
|
||||
argsJSON, _ := json.Marshal(tc.Args)
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, chat.ToolCall{
|
||||
ID: tc.ID,
|
||||
Type: "function",
|
||||
ProviderMetadata: tc.ProviderMetadata,
|
||||
Function: chat.FunctionCall{
|
||||
Name: tc.Name,
|
||||
Arguments: string(argsJSON),
|
||||
},
|
||||
})
|
||||
}
|
||||
msgs = append(msgs, assistantMsg)
|
||||
for _, tc := range nonTerminalCalls {
|
||||
msgs = append(msgs, chat.Message{
|
||||
Role: "tool",
|
||||
Content: toolCallOutput(tc),
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finalContent := agentHistoryThinkTagRegex.ReplaceAllString(m.Content, "")
|
||||
finalContent = strings.TrimSpace(finalContent)
|
||||
if finalContent != "" {
|
||||
msgs = append(msgs, chat.Message{Role: "assistant", Content: finalContent})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// legacyFinalAnswerToolName is the name of the now-removed final_answer tool.
|
||||
// It is retained here only to filter such calls out of OLD persisted agent
|
||||
// histories: pre-existing conversations recorded a final_answer tool call as
|
||||
// the terminal step, and the canonical answer text is replayed via the
|
||||
// trailing assistant message instead. Re-injecting it would duplicate the
|
||||
// answer or confuse the model into thinking the previous turn is mid-flight.
|
||||
const legacyFinalAnswerToolName = "final_answer"
|
||||
|
||||
// filterNonTerminalToolCalls drops legacy final_answer entries from historical
|
||||
// tool calls (see legacyFinalAnswerToolName), plus the pipeline stages a
|
||||
// fast-answer turn records for its own timeline (see
|
||||
// types.PipelineToolCallIDPrefix): the model never issued those, so replaying
|
||||
// them would attribute calls to it that it cannot answer for.
|
||||
func filterNonTerminalToolCalls(calls []types.ToolCall) []types.ToolCall {
|
||||
out := make([]types.ToolCall, 0, len(calls))
|
||||
for _, tc := range calls {
|
||||
if tc.Name == legacyFinalAnswerToolName || types.IsPipelineToolCallID(tc.ID) {
|
||||
continue
|
||||
}
|
||||
out = append(out, tc)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toolCallOutput returns the textual content to use for a historical tool
|
||||
// message. Failures still go through CompactToolOutputForHistory so stdout
|
||||
// from a crashed skill script is not dropped in favor of a one-line exit code.
|
||||
func toolCallOutput(tc types.ToolCall) string {
|
||||
if tc.Result == nil {
|
||||
return ""
|
||||
}
|
||||
return agenttools.CompactToolOutputForHistory(tc.Name, tc.Result)
|
||||
}
|
||||
|
||||
// extractImageCaptionsFromMessage concatenates non-empty Caption fields from
|
||||
// stored message images. Mirrors the helper used in chat_pipeline so both
|
||||
// modes surface previous-turn image descriptions identically.
|
||||
func extractImageCaptionsFromMessage(images types.MessageImages) string {
|
||||
var parts []string
|
||||
for _, img := range images {
|
||||
if img.Caption != "" {
|
||||
parts = append(parts, img.Caption)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
agenttools "github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestBuildUserHistoryMessage_IgnoresLegacyRenderedContent verifies that old
|
||||
// prompt/context snapshots never re-enter the current model context.
|
||||
func TestBuildUserHistoryMessage_IgnoresLegacyRenderedContent(t *testing.T) {
|
||||
msg := &types.Message{
|
||||
Role: "user",
|
||||
Content: "what about the chart?",
|
||||
RenderedContent: "what about the chart? [augmented]",
|
||||
Images: types.MessageImages{
|
||||
{Caption: "a bar chart"},
|
||||
},
|
||||
}
|
||||
got := buildUserHistoryMessage(msg)
|
||||
assert.Equal(t, "user", got.Role)
|
||||
assert.Equal(t, "what about the chart?\n\n[用户上传图片内容]\na bar chart", got.Content)
|
||||
assert.NotContains(t, got.Content, "[augmented]")
|
||||
}
|
||||
|
||||
func TestBuildUserHistoryMessage_FallsBackToContentWithCaptions(t *testing.T) {
|
||||
msg := &types.Message{
|
||||
Role: "user",
|
||||
Content: "look at this",
|
||||
Images: types.MessageImages{
|
||||
{Caption: "a bar chart"},
|
||||
{Caption: "a pie chart"},
|
||||
},
|
||||
}
|
||||
got := buildUserHistoryMessage(msg)
|
||||
assert.Equal(t, "user", got.Role)
|
||||
assert.Equal(t, "look at this\n\n[用户上传图片内容]\na bar chart\na pie chart", got.Content)
|
||||
}
|
||||
|
||||
// TestBuildUserHistoryMessage_AppendsAttachmentsWhenNoRenderedContent covers
|
||||
// the Agent-mode multi-turn path: AgentQA does not persist RenderedContent, so
|
||||
// the next turn's history must reconstruct the original attachment prompt from
|
||||
// the stored Attachments column. Otherwise, follow-up questions like "what is
|
||||
// in there?" lose all reference to the uploaded file.
|
||||
func TestBuildUserHistoryMessage_AppendsAttachmentsWhenNoRenderedContent(t *testing.T) {
|
||||
msg := &types.Message{
|
||||
Role: "user",
|
||||
Content: "summarize this",
|
||||
Attachments: types.MessageAttachments{
|
||||
{
|
||||
FileName: "report.pdf",
|
||||
FileType: ".pdf",
|
||||
FileSize: 2048,
|
||||
Content: "hello world",
|
||||
},
|
||||
},
|
||||
}
|
||||
got := buildUserHistoryMessage(msg)
|
||||
assert.Equal(t, "user", got.Role)
|
||||
assert.Contains(t, got.Content, "summarize this")
|
||||
assert.Contains(t, got.Content, `<attachment index="1" name="report.pdf">`)
|
||||
assert.Contains(t, got.Content, "hello world")
|
||||
}
|
||||
|
||||
// TestBuildUserHistoryMessage_RenderedContentRebuildsAttachment verifies that
|
||||
// canonical attachment data is retained while the legacy rendered snapshot is
|
||||
// discarded.
|
||||
func TestBuildUserHistoryMessage_RenderedContentRebuildsAttachment(t *testing.T) {
|
||||
msg := &types.Message{
|
||||
Role: "user",
|
||||
Content: "summarize this",
|
||||
RenderedContent: "summarize this [with retrieval context already included]",
|
||||
Attachments: types.MessageAttachments{
|
||||
{FileName: "report.pdf", FileType: ".pdf", Content: "hello"},
|
||||
},
|
||||
}
|
||||
got := buildUserHistoryMessage(msg)
|
||||
assert.Contains(t, got.Content, "summarize this")
|
||||
assert.NotContains(t, got.Content, "[with retrieval context already included]")
|
||||
assert.Contains(t, got.Content, "<attachment")
|
||||
}
|
||||
|
||||
// TestBuildAssistantHistoryMessages_NaturalFinishEmitsSingleAnswer covers the
|
||||
// most common path: a turn with no tool calls (model answered directly). The
|
||||
// result must be a single assistant message holding the canonical answer —
|
||||
// duplicates would inflate token usage every turn.
|
||||
func TestBuildAssistantHistoryMessages_NaturalFinishEmitsSingleAnswer(t *testing.T) {
|
||||
msg := &types.Message{
|
||||
Role: "assistant",
|
||||
Content: "Hello, nice to meet you!",
|
||||
AgentSteps: types.AgentSteps{
|
||||
{Iteration: 0, Thought: "Hello, nice to meet you!", ToolCalls: nil},
|
||||
},
|
||||
}
|
||||
got := buildAssistantHistoryMessages(msg)
|
||||
if assert.Len(t, got, 1) {
|
||||
assert.Equal(t, "assistant", got[0].Role)
|
||||
assert.Equal(t, "Hello, nice to meet you!", got[0].Content)
|
||||
assert.Empty(t, got[0].ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAssistantHistoryMessages_StripsThinkBlocks ensures the trailing
|
||||
// final-answer assistant message has any <think>…</think> blocks stripped, so
|
||||
// internal-reasoning text doesn't leak into the next turn's context.
|
||||
func TestBuildAssistantHistoryMessages_StripsThinkBlocks(t *testing.T) {
|
||||
msg := &types.Message{
|
||||
Role: "assistant",
|
||||
Content: "<think>plotting...</think>The answer is 42.",
|
||||
}
|
||||
got := buildAssistantHistoryMessages(msg)
|
||||
if assert.Len(t, got, 1) {
|
||||
assert.Equal(t, "The answer is 42.", got[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAssistantHistoryMessages_ToolCallsExpandIntoOpenAIShape covers the
|
||||
// option-B replay: non-terminal tool calls from AgentSteps become proper
|
||||
// assistant_with_tool_calls + tool messages, and the canonical final answer is
|
||||
// appended last. final_answer entries are filtered because they're terminal
|
||||
// signals — the trailing assistant message already carries the answer.
|
||||
func TestBuildAssistantHistoryMessages_ToolCallsExpandIntoOpenAIShape(t *testing.T) {
|
||||
msg := &types.Message{
|
||||
Role: "assistant",
|
||||
Content: "Found 3 matches in the docs.",
|
||||
AgentSteps: types.AgentSteps{
|
||||
{
|
||||
Iteration: 0,
|
||||
Thought: "Let me search.",
|
||||
ToolCalls: []types.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Name: agenttools.ToolKnowledgeSearch,
|
||||
Args: map[string]interface{}{"query": "foo"},
|
||||
Result: &types.ToolResult{
|
||||
Success: true,
|
||||
Output: "doc A, doc B, doc C",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Iteration: 1,
|
||||
Thought: "",
|
||||
ToolCalls: []types.ToolCall{
|
||||
{
|
||||
// Legacy persisted data: old conversations recorded a
|
||||
// final_answer terminal tool call. The filter still drops it.
|
||||
ID: "call_2",
|
||||
Name: "final_answer",
|
||||
Args: map[string]interface{}{"answer": "Found 3 matches in the docs."},
|
||||
Result: &types.ToolResult{Success: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := buildAssistantHistoryMessages(msg)
|
||||
if !assert.Len(t, got, 3) {
|
||||
return
|
||||
}
|
||||
// 1. assistant message announcing the tool call
|
||||
assert.Equal(t, "assistant", got[0].Role)
|
||||
assert.Equal(t, "Let me search.", got[0].Content)
|
||||
if assert.Len(t, got[0].ToolCalls, 1) {
|
||||
assert.Equal(t, "call_1", got[0].ToolCalls[0].ID)
|
||||
assert.Equal(t, agenttools.ToolKnowledgeSearch, got[0].ToolCalls[0].Function.Name)
|
||||
assert.Contains(t, got[0].ToolCalls[0].Function.Arguments, "foo")
|
||||
}
|
||||
// 2. tool result paired with the call ID
|
||||
assert.Equal(t, "tool", got[1].Role)
|
||||
assert.Equal(t, "call_1", got[1].ToolCallID)
|
||||
assert.Equal(t, "doc A, doc B, doc C", got[1].Content)
|
||||
// 3. canonical final answer (final_answer tool call itself was filtered)
|
||||
assert.Equal(t, "assistant", got[2].Role)
|
||||
assert.Equal(t, "Found 3 matches in the docs.", got[2].Content)
|
||||
assert.Empty(t, got[2].ToolCalls)
|
||||
}
|
||||
|
||||
// A fast-answer turn persists its retrieval stages so the UI can redraw the
|
||||
// timeline after a reload. Those calls came from the pipeline, not the model, so
|
||||
// replaying them would hand the model tool calls it never made — and, in a
|
||||
// KnowledgeQA turn, tools it was never offered.
|
||||
func TestBuildAssistantHistoryMessages_SkipsPipelineTimelineToolCalls(t *testing.T) {
|
||||
msg := &types.Message{
|
||||
Role: "assistant",
|
||||
Content: "你好!很高兴见到你。",
|
||||
AgentSteps: types.AgentSteps{
|
||||
{
|
||||
Iteration: 0,
|
||||
ToolCalls: []types.ToolCall{
|
||||
{
|
||||
ID: types.PipelineToolCallIDPrefix + "abc",
|
||||
Name: agenttools.ToolKnowledgeSearch,
|
||||
Args: map[string]interface{}{"query": "你好"},
|
||||
Result: &types.ToolResult{Success: true, Output: "未检索到相关内容"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := buildAssistantHistoryMessages(msg)
|
||||
if !assert.Len(t, got, 1) {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, "assistant", got[0].Role)
|
||||
assert.Equal(t, "你好!很高兴见到你。", got[0].Content)
|
||||
assert.Empty(t, got[0].ToolCalls)
|
||||
}
|
||||
|
||||
// TestBuildAssistantHistoryMessages_ToolFailureSurfacesAsError ensures a
|
||||
// historical failed tool call is replayed as an "Error: …" tool message so the
|
||||
// model can see (and avoid retrying) the same failure path.
|
||||
func TestBuildAssistantHistoryMessages_ToolFailureSurfacesAsError(t *testing.T) {
|
||||
msg := &types.Message{
|
||||
Role: "assistant",
|
||||
Content: "Sorry, I could not complete the search.",
|
||||
AgentSteps: types.AgentSteps{
|
||||
{
|
||||
Iteration: 0,
|
||||
Thought: "Trying search.",
|
||||
ToolCalls: []types.ToolCall{
|
||||
{
|
||||
ID: "call_err",
|
||||
Name: agenttools.ToolKnowledgeSearch,
|
||||
Args: map[string]interface{}{"query": "x"},
|
||||
Result: &types.ToolResult{
|
||||
Success: false,
|
||||
Error: "kb unreachable",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := buildAssistantHistoryMessages(msg)
|
||||
if !assert.Len(t, got, 3) {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, chat.Message{
|
||||
Role: "tool",
|
||||
Content: "Error: kb unreachable",
|
||||
ToolCallID: "call_err",
|
||||
Name: agenttools.ToolKnowledgeSearch,
|
||||
}, got[1])
|
||||
}
|
||||
|
||||
func TestBuildAssistantHistoryMessages_SkillScriptFailureKeepsStdout(t *testing.T) {
|
||||
stdout := `{"chart":{"success":false,"error":{"error":"X轴字段不存在:工作项目"}}}`
|
||||
msg := &types.Message{
|
||||
Role: "assistant",
|
||||
Content: "I will retry with a different axis.",
|
||||
AgentSteps: types.AgentSteps{
|
||||
{
|
||||
Iteration: 0,
|
||||
Thought: "Plot the chart.",
|
||||
ToolCalls: []types.ToolCall{
|
||||
{
|
||||
ID: "call_skill",
|
||||
Name: agenttools.ToolExecuteSkillScript,
|
||||
Args: map[string]interface{}{"skill_name": "smart-charts", "script_path": "scripts/cli.py"},
|
||||
Result: &types.ToolResult{
|
||||
Success: false,
|
||||
Output: "=== Script Execution ===\n" + stdout,
|
||||
Error: "Script exited with code 1",
|
||||
Data: map[string]interface{}{
|
||||
"display_type": "shell_exec",
|
||||
"stdout": stdout,
|
||||
"exit_code": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := buildAssistantHistoryMessages(msg)
|
||||
require.Len(t, got, 3)
|
||||
assert.Equal(t, "tool", got[1].Role)
|
||||
assert.Contains(t, got[1].Content, "X轴字段不存在:工作项目")
|
||||
assert.Contains(t, got[1].Content, "Error: Script exited with code 1")
|
||||
}
|
||||
|
||||
// TestFilterNonTerminalToolCalls confirms a legacy final_answer entry is
|
||||
// dropped — every other tool (KB search, web search, MCP tools…) must survive.
|
||||
func TestFilterNonTerminalToolCalls(t *testing.T) {
|
||||
in := []types.ToolCall{
|
||||
{Name: agenttools.ToolKnowledgeSearch},
|
||||
{Name: "final_answer"},
|
||||
{Name: agenttools.ToolWebSearch},
|
||||
}
|
||||
out := filterNonTerminalToolCalls(in)
|
||||
if assert.Len(t, out, 2) {
|
||||
assert.Equal(t, agenttools.ToolKnowledgeSearch, out[0].Name)
|
||||
assert.Equal(t, agenttools.ToolWebSearch, out[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAssistantHistoryMessages_ReplaysReasoningContent guards the
|
||||
// cross-turn replay path: AgentStep.ReasoningContent persisted on a prior turn
|
||||
// must be re-attached to the rebuilt assistant message, otherwise MiMo and
|
||||
// DeepSeek thinking-mode reject the next turn with HTTP 400 (issue #1302).
|
||||
func TestBuildAssistantHistoryMessages_ReplaysReasoningContent(t *testing.T) {
|
||||
msg := &types.Message{
|
||||
Role: "assistant",
|
||||
Content: "Found 3 matches in the docs.",
|
||||
AgentSteps: types.AgentSteps{
|
||||
{
|
||||
Iteration: 0,
|
||||
Thought: "Let me search.",
|
||||
ReasoningContent: "model's chain of thought",
|
||||
ToolCalls: []types.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: agenttools.ToolKnowledgeSearch,
|
||||
Args: map[string]interface{}{"query": "foo"},
|
||||
ProviderMetadata: types.ToolCallMetadata{"google": json.RawMessage(`{"thought_signature":"gemini-history-signature"}`)},
|
||||
Result: &types.ToolResult{
|
||||
Success: true,
|
||||
Output: "doc A",
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := buildAssistantHistoryMessages(msg)
|
||||
if !assert.Len(t, got, 3) {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, "model's chain of thought", got[0].ReasoningContent,
|
||||
"reasoning_content from AgentStep must be replayed onto the rebuilt assistant message "+
|
||||
"so MiMo/DeepSeek thinking-mode does not 400 on multi-turn (issue #1302)")
|
||||
require.Len(t, got[0].ToolCalls, 1)
|
||||
assert.JSONEq(t, `{"thought_signature":"gemini-history-signature"}`,
|
||||
string(got[0].ToolCalls[0].ProviderMetadata["google"]))
|
||||
// Tool message and final answer message must NOT carry reasoning_content.
|
||||
assert.Empty(t, got[1].ReasoningContent)
|
||||
assert.Empty(t, got[2].ReasoningContent)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// buildAgentConfig copies CustomAgentConfig into the runtime AgentConfig field
|
||||
// by field, and MemoryEnabled was missing from that list. Nothing failed
|
||||
// loudly: the agent path read a nil preference and treated every agent as
|
||||
// inheriting the workspace, so an agent explicitly barred from memory still
|
||||
// got the user's memories injected into its prompt.
|
||||
func TestAgentConfigCarriesTheMemoryPreference(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
want *bool
|
||||
}{
|
||||
{name: "opted out", want: boolPtr(false)},
|
||||
{name: "opted in", want: boolPtr(true)},
|
||||
{name: "inherits the workspace", want: nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := &sessionService{
|
||||
cfg: &config.Config{},
|
||||
webSearchProviderRepo: &sharedAgentWebSearchRepo{},
|
||||
}
|
||||
req := &types.QARequest{
|
||||
Session: &types.Session{ID: "session-1", TenantID: 1},
|
||||
CustomAgent: &types.CustomAgent{
|
||||
TenantID: 1,
|
||||
Config: types.CustomAgentConfig{
|
||||
MaxIterations: 5,
|
||||
MemoryEnabled: tc.want,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
agentConfig, err := svc.buildAgentConfig(t.Context(), req, &types.Tenant{ID: 1}, 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.want, agentConfig.MemoryEnabled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConfigCarriesMaxCompletionTokens(t *testing.T) {
|
||||
svc := &sessionService{
|
||||
cfg: &config.Config{},
|
||||
webSearchProviderRepo: &sharedAgentWebSearchRepo{},
|
||||
}
|
||||
req := &types.QARequest{
|
||||
Session: &types.Session{ID: "session-1", TenantID: 1},
|
||||
CustomAgent: &types.CustomAgent{
|
||||
TenantID: 1,
|
||||
Config: types.CustomAgentConfig{
|
||||
MaxIterations: 5,
|
||||
MaxCompletionTokens: 64000,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
agentConfig, err := svc.buildAgentConfig(t.Context(), req, &types.Tenant{ID: 1}, 1)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 64000, agentConfig.MaxCompletionTokens)
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
@@ -0,0 +1,97 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubMemoryAvailability struct {
|
||||
interfaces.MemoryService
|
||||
|
||||
available bool
|
||||
sawDisabledContext bool
|
||||
}
|
||||
|
||||
// MemoryAvailable mirrors what the real service does with the agent marker, so
|
||||
// that a registerTools which forgot to apply the agent's preference would fail
|
||||
// these tests rather than quietly pass them.
|
||||
func (s *stubMemoryAvailability) MemoryAvailable(ctx context.Context) bool {
|
||||
allowed := types.MemoryAllowedForAgent(ctx)
|
||||
s.sawDisabledContext = !allowed
|
||||
return s.available && allowed
|
||||
}
|
||||
|
||||
// registerToolsFor runs the registration pipeline with no knowledge scope, so
|
||||
// only the tools this test cares about survive it.
|
||||
func registerToolsFor(
|
||||
t *testing.T, memory *stubMemoryAvailability, config *types.AgentConfig,
|
||||
) *tools.ToolRegistry {
|
||||
t.Helper()
|
||||
registry := tools.NewToolRegistry()
|
||||
svc := &agentService{memoryService: memory}
|
||||
require.NoError(t, svc.registerTools(t.Context(), registry, config, nil, nil, "session-1"))
|
||||
return registry
|
||||
}
|
||||
|
||||
func hasTool(registry *tools.ToolRegistry, name string) bool {
|
||||
_, err := registry.GetTool(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Memory search follows the memory switches, not the tool list: an agent whose
|
||||
// allowlist never mentions it still gets it while memory is on.
|
||||
func TestMemorySearchIsInjectedWithoutBeingAllowlisted(t *testing.T) {
|
||||
memory := &stubMemoryAvailability{available: true}
|
||||
registry := registerToolsFor(t, memory, &types.AgentConfig{
|
||||
AllowedTools: []string{tools.ToolThinking},
|
||||
})
|
||||
|
||||
require.True(t, hasTool(registry, tools.ToolSearchMemory))
|
||||
require.True(t, hasTool(registry, tools.ToolThinking), "the rest of the allowlist is untouched")
|
||||
}
|
||||
|
||||
// The mirror image, and the reason the tool is stripped before it is re-added:
|
||||
// a config saved while memory was on, or a preset that names the tool, must not
|
||||
// outlive the workspace or the user switching memory off.
|
||||
func TestAStaleAllowlistEntryDoesNotSurviveMemoryBeingOff(t *testing.T) {
|
||||
memory := &stubMemoryAvailability{available: false}
|
||||
registry := registerToolsFor(t, memory, &types.AgentConfig{
|
||||
AllowedTools: []string{tools.ToolThinking, tools.ToolSearchMemory},
|
||||
})
|
||||
|
||||
require.False(t, hasTool(registry, tools.ToolSearchMemory))
|
||||
require.True(t, hasTool(registry, tools.ToolThinking))
|
||||
}
|
||||
|
||||
// The agent's own opt out is a third switch, and the engine runs on a context
|
||||
// that does not carry it. If it were not applied here an agent explicitly
|
||||
// barred from memory would still be handed a tool that reads it.
|
||||
func TestAnAgentOptedOutOfMemoryGetsNoMemoryTool(t *testing.T) {
|
||||
disabled := false
|
||||
memory := &stubMemoryAvailability{available: true}
|
||||
registry := registerToolsFor(t, memory, &types.AgentConfig{
|
||||
AllowedTools: []string{tools.ToolThinking},
|
||||
MemoryEnabled: &disabled,
|
||||
})
|
||||
|
||||
require.True(t, memory.sawDisabledContext,
|
||||
"the agent preference must reach the service as a marked context")
|
||||
require.False(t, hasTool(registry, tools.ToolSearchMemory))
|
||||
}
|
||||
|
||||
// A deployment without the memory service must not panic its way through tool
|
||||
// registration.
|
||||
func TestMemorySearchIsSkippedWhenThereIsNoMemoryService(t *testing.T) {
|
||||
registry := tools.NewToolRegistry()
|
||||
svc := &agentService{}
|
||||
require.NoError(t, svc.registerTools(t.Context(), registry,
|
||||
&types.AgentConfig{AllowedTools: []string{tools.ToolThinking, tools.ToolSearchMemory}},
|
||||
nil, nil, "session-1"))
|
||||
|
||||
require.False(t, hasTool(registry, tools.ToolSearchMemory))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAgentRequiresRerankModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
agent *types.CustomAgent
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "knowledge search with all knowledge bases",
|
||||
agent: &types.CustomAgent{Config: types.CustomAgentConfig{
|
||||
KBSelectionMode: "all",
|
||||
AllowedTools: []string{tools.ToolKnowledgeSearch},
|
||||
}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "knowledge search with selected knowledge bases",
|
||||
agent: &types.CustomAgent{Config: types.CustomAgentConfig{
|
||||
KBSelectionMode: "selected",
|
||||
AllowedTools: []string{tools.ToolKnowledgeSearch},
|
||||
}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "knowledge search with knowledge bases disabled",
|
||||
agent: &types.CustomAgent{Config: types.CustomAgentConfig{
|
||||
KBSelectionMode: "none",
|
||||
AllowedTools: []string{tools.ToolKnowledgeSearch},
|
||||
}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "default tools with knowledge bases disabled",
|
||||
agent: &types.CustomAgent{Config: types.CustomAgentConfig{
|
||||
KBSelectionMode: "none",
|
||||
}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "wiki tools do not use reranker",
|
||||
agent: &types.CustomAgent{Config: types.CustomAgentConfig{
|
||||
KBSelectionMode: "all",
|
||||
AllowedTools: []string{"wiki_search", "wiki_read_page"},
|
||||
}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil agent",
|
||||
agent: nil,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, agentRequiresRerankModel(tt.agent))
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/sandbox"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubInstallShellExecutor records the options the privileged executor is
|
||||
// asked for, so a test can prove the difference between the two shell_exec
|
||||
// wirings rather than merely that one was registered.
|
||||
type stubInstallShellExecutor struct {
|
||||
calls []sandbox.ShellExecOptions
|
||||
}
|
||||
|
||||
func (s *stubInstallShellExecutor) ExecShellCommandWithOptions(
|
||||
_ context.Context, _ string, _ string, opts sandbox.ShellExecOptions,
|
||||
) (*sandbox.ExecuteResult, error) {
|
||||
s.calls = append(s.calls, opts)
|
||||
return &sandbox.ExecuteResult{ExitCode: 0}, nil
|
||||
}
|
||||
|
||||
func installShellToolContext() context.Context {
|
||||
return tools.WithToolExecContext(context.Background(), &tools.ToolExecContext{SessionID: "sess-1"})
|
||||
}
|
||||
|
||||
func TestInstallerAgentGetsTheRootShellWithTheSkillsRoot(t *testing.T) {
|
||||
privileged := &stubInstallShellExecutor{}
|
||||
ordinary := &stubShellExecutor{}
|
||||
mgr := &capableManager{
|
||||
typ: sandbox.SandboxTypeE2B,
|
||||
shell: ordinary,
|
||||
installShell: privileged,
|
||||
}
|
||||
config := &types.AgentConfig{}
|
||||
config.EnableSkillInstallMode(types.BuiltinSkillInstallerID, sandbox.SkillsImageRoot+"/pptx")
|
||||
registry := tools.NewToolRegistry()
|
||||
|
||||
(&agentService{}).registerSandboxShellTool(context.Background(), registry, mgr, config)
|
||||
|
||||
result, err := registry.ExecuteTool(installShellToolContext(), tools.ToolShellExec,
|
||||
json.RawMessage(`{"command":"pip install -r requirements.txt","work_dir":"`+
|
||||
sandbox.SkillsImageRoot+`/sk-1"}`))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Success, result.Error)
|
||||
require.False(t, ordinary.called,
|
||||
"the installer must not fall through to the unprivileged executor")
|
||||
require.Len(t, privileged.calls, 1)
|
||||
require.True(t, privileged.calls[0].AsRoot)
|
||||
require.True(t, privileged.calls[0].AllowSkillsRoot)
|
||||
require.Equal(t, sandbox.SkillsImageRoot+"/sk-1", privileged.calls[0].WorkDir)
|
||||
}
|
||||
|
||||
func TestOrdinaryAgentKeepsTheUnprivilegedShell(t *testing.T) {
|
||||
privileged := &stubInstallShellExecutor{}
|
||||
ordinary := &stubShellExecutor{}
|
||||
mgr := &capableManager{
|
||||
typ: sandbox.SandboxTypeE2B,
|
||||
shell: ordinary,
|
||||
installShell: privileged,
|
||||
}
|
||||
registry := tools.NewToolRegistry()
|
||||
|
||||
(&agentService{}).registerSandboxShellTool(context.Background(), registry, mgr, &types.AgentConfig{})
|
||||
|
||||
rejected, err := registry.ExecuteTool(installShellToolContext(), tools.ToolShellExec,
|
||||
json.RawMessage(`{"command":"pip install x","work_dir":"`+sandbox.SkillsImageRoot+`/sk-1"}`))
|
||||
require.NoError(t, err)
|
||||
require.False(t, rejected.Success, "an ordinary agent has no business under /opt")
|
||||
|
||||
accepted, err := registry.ExecuteTool(installShellToolContext(), tools.ToolShellExec,
|
||||
json.RawMessage(`{"command":"ls"}`))
|
||||
require.NoError(t, err)
|
||||
require.True(t, accepted.Success)
|
||||
require.True(t, ordinary.called)
|
||||
require.Empty(t, privileged.calls,
|
||||
"nothing an ordinary agent can ask for may reach the root executor")
|
||||
}
|
||||
|
||||
func TestInstallModeIsRefusedToEveryAgentButTheInstaller(t *testing.T) {
|
||||
config := &types.AgentConfig{}
|
||||
|
||||
config.EnableSkillInstallMode("some-tenant-agent", sandbox.SkillsImageRoot+"/pptx")
|
||||
|
||||
require.False(t, config.SkillInstallMode())
|
||||
}
|
||||
|
||||
func TestInstallModeSurvivesNoJSONRoundTrip(t *testing.T) {
|
||||
// The flag must be unreachable from stored agent records and API payloads:
|
||||
// both arrive as JSON.
|
||||
var config types.AgentConfig
|
||||
require.NoError(t, json.Unmarshal(
|
||||
[]byte(`{"skill_install_mode":true,"SkillInstallMode":true}`), &config))
|
||||
|
||||
require.False(t, config.SkillInstallMode())
|
||||
}
|
||||
|
||||
func TestInstallerAgentConfigTurnsOnInstallMode(t *testing.T) {
|
||||
config := installerAgentConfig(&types.CustomAgent{
|
||||
ID: types.BuiltinSkillInstallerID,
|
||||
Config: types.CustomAgentConfig{AllowedTools: []string{tools.ToolShellExec}},
|
||||
}, "cfg-1", sandbox.SkillsImageRoot+"/pptx")
|
||||
|
||||
require.True(t, config.SkillInstallMode())
|
||||
require.Equal(t, "none", config.MCPSelectionMode,
|
||||
"empty MCPSelectionMode defaults to all tenant MCP tools on a root shell")
|
||||
require.NotNil(t, config.MemoryEnabled)
|
||||
require.False(t, *config.MemoryEnabled,
|
||||
"nil MemoryEnabled inherits the workspace and would register search_memory")
|
||||
require.False(t, config.WebSearchEnabled)
|
||||
}
|
||||
|
||||
func TestInstallerAgentConfigKeepsMCPOffWhenThePlatformYAMLEnablesIt(t *testing.T) {
|
||||
memoryOn := true
|
||||
config := installerAgentConfig(&types.CustomAgent{
|
||||
ID: types.BuiltinSkillInstallerID,
|
||||
Config: types.CustomAgentConfig{
|
||||
AllowedTools: []string{tools.ToolShellExec},
|
||||
MCPSelectionMode: "all",
|
||||
WebSearchEnabled: true,
|
||||
MemoryEnabled: &memoryOn,
|
||||
},
|
||||
}, "cfg-1", sandbox.SkillsImageRoot+"/pptx")
|
||||
|
||||
require.Equal(t, "none", config.MCPSelectionMode)
|
||||
require.False(t, config.WebSearchEnabled)
|
||||
require.NotNil(t, config.MemoryEnabled)
|
||||
require.False(t, *config.MemoryEnabled)
|
||||
}
|
||||
|
||||
func TestInstallerAgentConfigLeavesInstallModeOffForAnotherAgent(t *testing.T) {
|
||||
config := installerAgentConfig(&types.CustomAgent{
|
||||
ID: "agent-42",
|
||||
Config: types.CustomAgentConfig{AllowedTools: []string{tools.ToolShellExec}},
|
||||
}, "cfg-1", sandbox.SkillsImageRoot+"/pptx")
|
||||
|
||||
require.False(t, config.SkillInstallMode())
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAgentHasKnowledgeScope_TagOnlySearchTargets(t *testing.T) {
|
||||
cfg := &types.AgentConfig{
|
||||
SearchTargets: types.SearchTargets{
|
||||
{
|
||||
Type: types.SearchTargetTypeKnowledgeBase,
|
||||
KnowledgeBaseID: "kb-1",
|
||||
TagIDs: []string{"tag-a"},
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.True(t, agentHasKnowledgeScope(cfg))
|
||||
}
|
||||
|
||||
func TestAgentHasKnowledgeScope_Empty(t *testing.T) {
|
||||
assert.False(t, agentHasKnowledgeScope(&types.AgentConfig{}))
|
||||
assert.False(t, agentHasKnowledgeScope(nil))
|
||||
}
|
||||
|
||||
func TestKnowledgeBaseScopesForPrompt_FromSearchTargets(t *testing.T) {
|
||||
cfg := &types.AgentConfig{
|
||||
SearchTargets: types.SearchTargets{
|
||||
{KnowledgeBaseID: "kb-1", TagIDs: []string{"tag-a"}},
|
||||
{KnowledgeBaseID: "kb-1", TagIDs: []string{"tag-b"}},
|
||||
{KnowledgeBaseID: "kb-2", TagIDs: []string{"tag-c"}},
|
||||
},
|
||||
}
|
||||
ids, _ := knowledgeBaseScopesForPrompt(cfg)
|
||||
assert.Equal(t, []string{"kb-1", "kb-2"}, ids)
|
||||
}
|
||||
|
||||
func TestKnowledgeBaseScopesForPrompt_CarriesSharedKBSourceTenant(t *testing.T) {
|
||||
cfg := &types.AgentConfig{
|
||||
SearchTargets: types.SearchTargets{
|
||||
{KnowledgeBaseID: "shared-kb", TenantID: 42},
|
||||
{KnowledgeBaseID: "own-kb", TenantID: 0},
|
||||
},
|
||||
}
|
||||
ids, tenantMap := knowledgeBaseScopesForPrompt(cfg)
|
||||
assert.Equal(t, []string{"shared-kb", "own-kb"}, ids)
|
||||
assert.Equal(t, uint64(42), tenantMap["shared-kb"])
|
||||
assert.Zero(t, tenantMap["own-kb"])
|
||||
}
|
||||
|
||||
// KnowledgeBases carries shared KB IDs too (KBSelectionMode="all" and @mentions
|
||||
// both write into it), so the source tenant must still come from SearchTargets.
|
||||
func TestKnowledgeBaseScopesForPrompt_ExplicitKnowledgeBasesKeepSourceTenant(t *testing.T) {
|
||||
cfg := &types.AgentConfig{
|
||||
KnowledgeBases: []string{"own-kb", "shared-kb"},
|
||||
SearchTargets: types.SearchTargets{
|
||||
{KnowledgeBaseID: "own-kb", TenantID: 7},
|
||||
{KnowledgeBaseID: "shared-kb", TenantID: 42},
|
||||
},
|
||||
}
|
||||
ids, tenantMap := knowledgeBaseScopesForPrompt(cfg)
|
||||
assert.Equal(t, []string{"own-kb", "shared-kb"}, ids)
|
||||
assert.Equal(t, uint64(42), tenantMap["shared-kb"])
|
||||
}
|
||||
|
||||
func TestKnowledgeBaseScopesForPrompt_NilConfig(t *testing.T) {
|
||||
ids, tenantMap := knowledgeBaseScopesForPrompt(nil)
|
||||
assert.Nil(t, ids)
|
||||
assert.Empty(t, tenantMap)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/sandbox"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// capableManager is a test double that lets each case declare which
|
||||
// session-scoped capabilities the sandbox manager currently advertises.
|
||||
// It mirrors the real SessionCapabilityProvider contract SessionBoundManager
|
||||
// implements, without pulling in the full manager wiring.
|
||||
type capableManager struct {
|
||||
typ sandbox.SandboxType
|
||||
shell sandbox.SessionShellExecutor
|
||||
files sandbox.SessionFileStore
|
||||
installShell sandbox.SessionInstallShellExecutor
|
||||
}
|
||||
|
||||
func (m *capableManager) SessionInstallShellExecutor() sandbox.SessionInstallShellExecutor {
|
||||
return m.installShell
|
||||
}
|
||||
|
||||
func (m *capableManager) Execute(context.Context, *sandbox.ExecuteConfig) (*sandbox.ExecuteResult, error) {
|
||||
return &sandbox.ExecuteResult{}, nil
|
||||
}
|
||||
func (m *capableManager) Cleanup(context.Context) error { return nil }
|
||||
func (m *capableManager) GetSandbox() sandbox.Sandbox { return nil }
|
||||
func (m *capableManager) GetType() sandbox.SandboxType { return m.typ }
|
||||
func (m *capableManager) SessionShellExecutor() sandbox.SessionShellExecutor {
|
||||
return m.shell
|
||||
}
|
||||
func (m *capableManager) SessionFileStore() sandbox.SessionFileStore {
|
||||
return m.files
|
||||
}
|
||||
|
||||
// stubShellExecutor records ExecShellCommand calls so a test can assert the
|
||||
// registered tool actually dispatches through it.
|
||||
type stubShellExecutor struct{ called bool }
|
||||
|
||||
func (s *stubShellExecutor) ExecShellCommand(
|
||||
context.Context, string, string, string, time.Duration, map[string]string,
|
||||
) (*sandbox.ExecuteResult, error) {
|
||||
s.called = true
|
||||
return &sandbox.ExecuteResult{}, nil
|
||||
}
|
||||
|
||||
func TestSessionSandboxShellExecutorReturnsNilWithoutCapability(t *testing.T) {
|
||||
// Managers that don't implement SessionCapabilityProvider (Disabled
|
||||
// DefaultManager) must never surface shell_exec.
|
||||
nonCapable := &capableManager{typ: sandbox.SandboxTypeDisabled}
|
||||
assert.Nil(t, sessionSandboxShellExecutor(nonCapable))
|
||||
assert.Nil(t, sessionSandboxFileStore(nonCapable))
|
||||
assert.Nil(t, sessionSandboxShellExecutor(nil))
|
||||
}
|
||||
|
||||
func TestSessionSandboxShellExecutorReturnsNilWhenProviderRefuses(t *testing.T) {
|
||||
// A provider that advertises capabilities but is currently unable to
|
||||
// honour them returns nil from the accessor. The tool layer must respect that.
|
||||
m := &capableManager{typ: sandbox.SandboxTypeCube}
|
||||
assert.Nil(t, sessionSandboxShellExecutor(m))
|
||||
assert.Nil(t, sessionSandboxFileStore(m))
|
||||
}
|
||||
|
||||
func TestSessionSandboxShellExecutorReturnsCapability(t *testing.T) {
|
||||
exec := &stubShellExecutor{}
|
||||
m := &capableManager{typ: sandbox.SandboxTypeCube, shell: exec}
|
||||
got := sessionSandboxShellExecutor(m)
|
||||
assert.NotNil(t, got)
|
||||
if _, err := got.ExecShellCommand(context.Background(), "sid", "echo", "", 0, nil); err != nil {
|
||||
t.Fatalf("dispatch through capability: %v", err)
|
||||
}
|
||||
assert.True(t, exec.called)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/application/repository"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type skillBundleFileMap struct {
|
||||
files map[string][]byte
|
||||
}
|
||||
|
||||
func (s skillBundleFileMap) CheckConnectivity(context.Context) error { return nil }
|
||||
func (s skillBundleFileMap) SaveFile(context.Context, *multipart.FileHeader, uint64, string) (string, error) {
|
||||
return "", errors.New("not implemented")
|
||||
}
|
||||
func (s skillBundleFileMap) SaveBytes(context.Context, []byte, uint64, string, bool) (string, error) {
|
||||
return "", errors.New("not implemented")
|
||||
}
|
||||
func (s skillBundleFileMap) GetFile(_ context.Context, ref string) (io.ReadCloser, error) {
|
||||
data, ok := s.files[ref]
|
||||
if !ok {
|
||||
return nil, errors.New("bundle not found")
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(data)), nil
|
||||
}
|
||||
func (s skillBundleFileMap) GetFileURL(context.Context, string) (string, error) { return "", nil }
|
||||
func (s skillBundleFileMap) DeleteFile(context.Context, string) error { return nil }
|
||||
func (s skillBundleFileMap) CopyFile(context.Context, string, uint64, string) (string, error) {
|
||||
return "", errors.New("not implemented")
|
||||
}
|
||||
|
||||
type skillBundleResolver struct{ fs interfaces.FileService }
|
||||
|
||||
func (r skillBundleResolver) ResolveFileService(
|
||||
context.Context, *types.Tenant, string, string, string,
|
||||
) (interfaces.FileService, string, error) {
|
||||
return r.fs, "", nil
|
||||
}
|
||||
func (skillBundleResolver) ResolveBackend(
|
||||
context.Context, *types.Tenant, string, string,
|
||||
) (*types.StorageBackend, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newAgentSkillBundleService(t *testing.T, files map[string][]byte) (*agentService, *gorm.DB) {
|
||||
t.Helper()
|
||||
dsn := "file:agent-skill-" + strings.ReplaceAll(t.Name(), "/", "_") + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&types.TenantSkillEntity{}, &types.TenantSkillCatalogEntity{},
|
||||
&types.TenantSkillSnapshotEntity{}, &types.TenantUserEnvVar{},
|
||||
))
|
||||
return &agentService{
|
||||
db: db,
|
||||
storageResolver: skillBundleResolver{fs: skillBundleFileMap{files: files}},
|
||||
}, db
|
||||
}
|
||||
|
||||
func zipSkillArchive(t *testing.T, extract string) []byte {
|
||||
t.Helper()
|
||||
archive, err := zipSkillFiles(map[string][]byte{
|
||||
"SKILL.md": []byte(validSkillMD),
|
||||
"scripts/extract.py": []byte(extract),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return archive
|
||||
}
|
||||
|
||||
func TestLoadInstalledSkillBundleReadsAMatchingCatalogArchive(t *testing.T) {
|
||||
v1 := zipSkillArchive(t, "print('v1')\n")
|
||||
svc, db := newAgentSkillBundleService(t, map[string][]byte{"file://catalog.zip": v1})
|
||||
ctx := context.Background()
|
||||
require.NoError(t, repository.NewTenantSkillRepository(db).CreateCatalog(ctx, &types.TenantSkillCatalogEntity{
|
||||
ID: "cat-1", TenantID: 7, Name: "pdf-tools",
|
||||
BundleRef: "file://catalog.zip", BundleSHA256: skillArchiveSHA256(v1),
|
||||
}))
|
||||
|
||||
got, err := svc.loadInstalledSkillBundle(ctx, 7, &types.TenantSkillEntity{
|
||||
Name: "pdf-tools", CatalogID: "cat-1", BundleSHA256: skillArchiveSHA256(v1),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, v1, got)
|
||||
}
|
||||
|
||||
func TestLoadInstalledSkillBundleRejectsAReplacedCatalogArchive(t *testing.T) {
|
||||
v2 := zipSkillArchive(t, "print('v2')\n")
|
||||
svc, db := newAgentSkillBundleService(t, map[string][]byte{"file://catalog.zip": v2})
|
||||
ctx := context.Background()
|
||||
require.NoError(t, repository.NewTenantSkillRepository(db).CreateCatalog(ctx, &types.TenantSkillCatalogEntity{
|
||||
ID: "cat-1", TenantID: 7, Name: "pdf-tools",
|
||||
BundleRef: "file://catalog.zip", BundleSHA256: skillArchiveSHA256(v2),
|
||||
}))
|
||||
|
||||
_, err := svc.loadInstalledSkillBundle(ctx, 7, &types.TenantSkillEntity{
|
||||
Name: "pdf-tools", CatalogID: "cat-1",
|
||||
BundleSHA256: strings.Repeat("d", 64),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "registered again from a different archive")
|
||||
}
|
||||
|
||||
func TestLoadInstalledSkillBundlePrefersAPinnedInstallRef(t *testing.T) {
|
||||
v1 := zipSkillArchive(t, "print('v1')\n")
|
||||
v2 := zipSkillArchive(t, "print('v2')\n")
|
||||
svc, db := newAgentSkillBundleService(t, map[string][]byte{
|
||||
"file://old.zip": v1,
|
||||
"file://catalog.zip": v2,
|
||||
})
|
||||
ctx := context.Background()
|
||||
require.NoError(t, repository.NewTenantSkillRepository(db).CreateCatalog(ctx, &types.TenantSkillCatalogEntity{
|
||||
ID: "cat-1", TenantID: 7, Name: "pdf-tools",
|
||||
BundleRef: "file://catalog.zip", BundleSHA256: skillArchiveSHA256(v2),
|
||||
}))
|
||||
|
||||
got, err := svc.loadInstalledSkillBundle(ctx, 7, &types.TenantSkillEntity{
|
||||
Name: "pdf-tools", CatalogID: "cat-1",
|
||||
BundleRef: "file://old.zip", BundleSHA256: skillArchiveSHA256(v1),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, v1, got)
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent"
|
||||
"github.com/Tencent/WeKnora/internal/agent/skills"
|
||||
"github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/sandbox"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeAgentKnowledgeBaseService struct {
|
||||
interfaces.KnowledgeBaseService
|
||||
kb *types.KnowledgeBase
|
||||
}
|
||||
|
||||
func (s *fakeAgentKnowledgeBaseService) GetKnowledgeBaseByID(context.Context, string) (*types.KnowledgeBase, error) {
|
||||
if s.kb == nil {
|
||||
return nil, errors.New("knowledge base not found")
|
||||
}
|
||||
return s.kb, nil
|
||||
}
|
||||
|
||||
type fakeAgentKnowledgeService struct {
|
||||
interfaces.KnowledgeService
|
||||
knowledges []*types.Knowledge
|
||||
lastFilter types.KnowledgeListFilter
|
||||
lastTenant uint64
|
||||
}
|
||||
|
||||
type fakeAgentChatModel struct {
|
||||
lastToolNames []string
|
||||
}
|
||||
|
||||
func (*fakeAgentChatModel) Chat(context.Context, []chat.Message, *chat.ChatOptions) (*types.ChatResponse, error) {
|
||||
return &types.ChatResponse{}, nil
|
||||
}
|
||||
|
||||
func (m *fakeAgentChatModel) ChatStream(
|
||||
_ context.Context, _ []chat.Message, opts *chat.ChatOptions,
|
||||
) (<-chan types.StreamResponse, error) {
|
||||
m.lastToolNames = nil
|
||||
if opts != nil {
|
||||
for _, tool := range opts.Tools {
|
||||
m.lastToolNames = append(m.lastToolNames, tool.Function.Name)
|
||||
}
|
||||
}
|
||||
|
||||
ch := make(chan types.StreamResponse, 1)
|
||||
ch <- types.StreamResponse{
|
||||
ResponseType: types.ResponseTypeAnswer,
|
||||
Content: "ok",
|
||||
Done: true,
|
||||
FinishReason: "stop",
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (*fakeAgentChatModel) GetModelName() string { return "fake-chat" }
|
||||
func (*fakeAgentChatModel) GetModelID() string { return "fake-chat-id" }
|
||||
|
||||
type stubSessionFileStore struct{}
|
||||
|
||||
func (stubSessionFileStore) EnsureSessionDir(context.Context, string, string) error { return nil }
|
||||
func (stubSessionFileStore) ListSessionFiles(context.Context, string, string) ([]sandbox.RemoteDirEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubSessionFileStore) StatSessionFile(context.Context, string, string) (*sandbox.RemoteStatEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubSessionFileStore) ReadSessionFile(context.Context, string, string) ([]byte, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (stubSessionFileStore) WriteSessionInputFile(context.Context, string, string, []byte) error {
|
||||
return nil
|
||||
}
|
||||
func (stubSessionFileStore) WriteSessionWorkspaceFile(context.Context, string, string, []byte) error {
|
||||
return nil
|
||||
}
|
||||
func (stubSessionFileStore) RemoveSessionInputPath(context.Context, string, string) error { return nil }
|
||||
|
||||
func (s *fakeAgentKnowledgeService) ListPagedKnowledgeByKnowledgeBaseID(
|
||||
ctx context.Context,
|
||||
_ string,
|
||||
page *types.Pagination,
|
||||
filter types.KnowledgeListFilter,
|
||||
) (*types.PageResult, error) {
|
||||
s.lastFilter = filter
|
||||
s.lastTenant, _ = types.TenantIDFromContext(ctx)
|
||||
|
||||
filtered := make([]*types.Knowledge, 0, len(s.knowledges))
|
||||
for _, knowledge := range s.knowledges {
|
||||
if filter.ParseStatus != "" && knowledge.ParseStatus != filter.ParseStatus {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, knowledge)
|
||||
}
|
||||
return types.NewPageResult(int64(len(filtered)), page, filtered), nil
|
||||
}
|
||||
|
||||
func toolRegistered(registry *tools.ToolRegistry, name string) bool {
|
||||
_, err := registry.GetTool(name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func toolOffered(names []string, name string) bool {
|
||||
for _, got := range names {
|
||||
if got == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestCreateAgentEngineOpensSandboxToolsOnlyForInstallMode pins the skill
|
||||
// gate on the skill installer alone for shell_exec. shell_exec follows
|
||||
// SkillsEnabled (or install mode). list/read/write/edit sandbox file tools
|
||||
// follow SessionFileStore for ordinary agents, including when skills are
|
||||
// disabled, but are withheld from the installer: those tools only accept
|
||||
// /workspace, and the installer must write the skill tree via shell_exec.
|
||||
func TestCreateAgentEngineOpensSandboxToolsOnlyForInstallMode(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(7))
|
||||
|
||||
t.Run("install-mode config gets shell_exec without skill tools", func(t *testing.T) {
|
||||
chatModel := &fakeAgentChatModel{}
|
||||
svc := &agentService{
|
||||
sandboxResolver: stubSandboxResolver{
|
||||
mgr: &capableManager{
|
||||
typ: sandbox.SandboxTypeCube,
|
||||
shell: &stubShellExecutor{},
|
||||
// The manager advertises a session file store, the way
|
||||
// every real remote backend does. The installer still
|
||||
// must not receive those tools: they only write /workspace.
|
||||
files: stubSessionFileStore{},
|
||||
installShell: &stubInstallShellExecutor{},
|
||||
},
|
||||
},
|
||||
}
|
||||
config := &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-remote",
|
||||
SkillsEnabled: false,
|
||||
AllowedTools: []string{tools.ToolShellExec},
|
||||
}
|
||||
config.EnableSkillInstallMode(types.BuiltinSkillInstallerID, sandbox.SkillsImageRoot+"/pptx")
|
||||
|
||||
engine, err := svc.CreateAgentEngine(ctx, config, chatModel, nil, nil, "sess-1", "msg-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
_, err = engine.Execute(ctx, "sess-1", "msg-1", "hello", nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolShellExec))
|
||||
require.False(t, toolOffered(chatModel.lastToolNames, tools.ToolReadSkill))
|
||||
require.False(t, toolOffered(chatModel.lastToolNames, tools.ToolExecuteSkillScript))
|
||||
require.False(t, toolOffered(chatModel.lastToolNames, tools.ToolListSandboxFiles),
|
||||
"session file tools only accept /workspace; the installer must write the skill tree via shell_exec")
|
||||
require.False(t, toolOffered(chatModel.lastToolNames, tools.ToolReadSandboxFile))
|
||||
require.False(t, toolOffered(chatModel.lastToolNames, tools.ToolWriteSandboxFile))
|
||||
require.False(t, toolOffered(chatModel.lastToolNames, tools.ToolEditSandboxFile))
|
||||
require.Nil(t, engine.(*agent.AgentEngine).GetSkillsManager())
|
||||
})
|
||||
|
||||
t.Run("an ordinary agent with skills off gets no shell but keeps file tools", func(t *testing.T) {
|
||||
chatModel := &fakeAgentChatModel{}
|
||||
svc := &agentService{
|
||||
sandboxResolver: stubSandboxResolver{
|
||||
mgr: &capableManager{
|
||||
typ: sandbox.SandboxTypeCube,
|
||||
shell: &stubShellExecutor{},
|
||||
files: stubSessionFileStore{},
|
||||
installShell: &stubInstallShellExecutor{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
engine, err := svc.CreateAgentEngine(ctx, &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-remote",
|
||||
SkillsEnabled: false,
|
||||
AllowedTools: []string{tools.ToolShellExec, tools.ToolThinking},
|
||||
}, chatModel, nil, nil, "sess-1", "msg-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
_, err = engine.Execute(ctx, "sess-1", "msg-1", "hello", nil)
|
||||
require.NoError(t, err)
|
||||
require.False(t, toolOffered(chatModel.lastToolNames, tools.ToolShellExec),
|
||||
"shell_exec follows SkillsEnabled; an agent with skills off gets no shell")
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolListSandboxFiles))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolReadSandboxFile))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolWriteSandboxFile))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolEditSandboxFile))
|
||||
require.Nil(t, engine.(*agent.AgentEngine).GetSkillsManager())
|
||||
})
|
||||
|
||||
t.Run("skills disabled without skills or install mode gets no shell or skill tools but keeps file tools", func(t *testing.T) {
|
||||
chatModel := &fakeAgentChatModel{}
|
||||
svc := &agentService{
|
||||
sandboxResolver: stubSandboxResolver{
|
||||
mgr: &capableManager{
|
||||
typ: sandbox.SandboxTypeCube,
|
||||
shell: &stubShellExecutor{},
|
||||
files: stubSessionFileStore{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
engine, err := svc.CreateAgentEngine(ctx, &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-remote",
|
||||
SkillsEnabled: false,
|
||||
AllowedTools: []string{tools.ToolThinking},
|
||||
}, chatModel, nil, nil, "sess-1", "msg-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
_, err = engine.Execute(ctx, "sess-1", "msg-1", "hello", nil)
|
||||
require.NoError(t, err)
|
||||
require.False(t, toolOffered(chatModel.lastToolNames, tools.ToolShellExec))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolListSandboxFiles))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolReadSandboxFile))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolWriteSandboxFile))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolEditSandboxFile))
|
||||
require.False(t, toolOffered(chatModel.lastToolNames, tools.ToolReadSkill))
|
||||
require.False(t, toolOffered(chatModel.lastToolNames, tools.ToolExecuteSkillScript))
|
||||
require.Nil(t, engine.(*agent.AgentEngine).GetSkillsManager())
|
||||
})
|
||||
|
||||
t.Run("skills enabled with skill dirs keeps existing behavior", func(t *testing.T) {
|
||||
chatModel := &fakeAgentChatModel{}
|
||||
svc := &agentService{
|
||||
sandboxResolver: stubSandboxResolver{
|
||||
mgr: &capableManager{
|
||||
typ: sandbox.SandboxTypeCube,
|
||||
shell: &stubShellExecutor{},
|
||||
files: stubSessionFileStore{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
engine, err := svc.CreateAgentEngine(ctx, &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-remote",
|
||||
SkillsEnabled: true,
|
||||
SkillDirs: []string{t.TempDir()},
|
||||
}, chatModel, nil, nil, "sess-1", "msg-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
_, err = engine.Execute(ctx, "sess-1", "msg-1", "hello", nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolShellExec))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolListSandboxFiles))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolReadSandboxFile))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolWriteSandboxFile))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolEditSandboxFile))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolReadSkill))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolExecuteSkillScript))
|
||||
require.NotNil(t, engine.(*agent.AgentEngine).GetSkillsManager())
|
||||
})
|
||||
|
||||
t.Run("skills enabled with tenant skills and no host dirs still offers skill tools", func(t *testing.T) {
|
||||
chatModel := &fakeAgentChatModel{}
|
||||
svc := &agentService{
|
||||
sandboxResolver: stubSandboxResolver{
|
||||
mgr: &capableManager{
|
||||
typ: sandbox.SandboxTypeCube,
|
||||
shell: &stubShellExecutor{},
|
||||
files: stubSessionFileStore{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
engine, err := svc.CreateAgentEngine(ctx, &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-remote",
|
||||
SkillsEnabled: true,
|
||||
TenantSkills: []*types.TenantSkillEntity{{
|
||||
ID: "sk-1", TenantID: 7, SandboxConfigID: "cfg-remote",
|
||||
Name: "pdf-tools", Description: "PDF helpers",
|
||||
Status: types.SkillStatusReady, Enabled: true,
|
||||
}},
|
||||
}, chatModel, nil, nil, "sess-1", "msg-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
_, err = engine.Execute(ctx, "sess-1", "msg-1", "hello", nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolReadSkill))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolExecuteSkillScript))
|
||||
mgr := engine.(*agent.AgentEngine).GetSkillsManager()
|
||||
require.NotNil(t, mgr)
|
||||
var names []string
|
||||
for _, meta := range mgr.GetAllMetadata() {
|
||||
names = append(names, meta.Name)
|
||||
}
|
||||
require.Equal(t, []string{"pdf-tools"}, names)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillToolsFollowSkillsEnabled(t *testing.T) {
|
||||
t.Run("skills disabled: initializeSkillsManager registers no skill tools or shell", func(t *testing.T) {
|
||||
registry := tools.NewToolRegistry()
|
||||
svc := &agentService{
|
||||
sandboxResolver: stubSandboxResolver{
|
||||
mgr: &capableManager{typ: sandbox.SandboxTypeCube, shell: &stubShellExecutor{}},
|
||||
},
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(7))
|
||||
|
||||
_, err := svc.initializeSkillsManager(ctx, "sess-1", &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-remote",
|
||||
SkillsEnabled: false,
|
||||
AllowedTools: []string{tools.ToolShellExec},
|
||||
}, registry)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, toolRegistered(registry, tools.ToolReadSkill))
|
||||
require.False(t, toolRegistered(registry, tools.ToolExecuteSkillScript))
|
||||
require.False(t, toolRegistered(registry, tools.ToolShellExec),
|
||||
"shell_exec is registered by registerSandboxShellIfAllowed, not initializeSkillsManager")
|
||||
})
|
||||
|
||||
t.Run("skills enabled: skill tools without shell", func(t *testing.T) {
|
||||
registry := tools.NewToolRegistry()
|
||||
svc := &agentService{
|
||||
sandboxResolver: stubSandboxResolver{
|
||||
mgr: &capableManager{typ: sandbox.SandboxTypeCube, shell: &stubShellExecutor{}},
|
||||
},
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(7))
|
||||
|
||||
_, err := svc.initializeSkillsManager(ctx, "sess-1", &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-remote",
|
||||
SkillsEnabled: true,
|
||||
SkillDirs: []string{t.TempDir()},
|
||||
}, registry)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, toolRegistered(registry, tools.ToolReadSkill))
|
||||
require.True(t, toolRegistered(registry, tools.ToolExecuteSkillScript))
|
||||
require.False(t, toolRegistered(registry, tools.ToolShellExec),
|
||||
"shell_exec is registered separately from the skills manager")
|
||||
})
|
||||
}
|
||||
|
||||
// shell_exec can execute skill scripts, so it follows SkillsEnabled rather
|
||||
// than the presence of a ready skill. An agent whose skills are enabled but
|
||||
// whose sandbox currently carries no ready skill must still receive the shell,
|
||||
// otherwise it has no way to inspect a fresh or still-installing sandbox.
|
||||
func TestCreateAgentEngineShellFollowsSkillsEnabledWithoutInstalledSkills(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(7))
|
||||
chatModel := &fakeAgentChatModel{}
|
||||
svc := &agentService{
|
||||
sandboxResolver: stubSandboxResolver{
|
||||
mgr: &capableManager{
|
||||
typ: sandbox.SandboxTypeCube,
|
||||
shell: &stubShellExecutor{},
|
||||
files: stubSessionFileStore{},
|
||||
installShell: &stubInstallShellExecutor{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
engine, err := svc.CreateAgentEngine(ctx, &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-remote",
|
||||
SkillsEnabled: true,
|
||||
// No SkillDirs and no TenantSkills: skills are enabled, but the sandbox
|
||||
// image carries none.
|
||||
}, chatModel, nil, nil, "sess-1", "msg-1")
|
||||
|
||||
require.NoError(t, err)
|
||||
_, err = engine.Execute(ctx, "sess-1", "msg-1", "hello", nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolShellExec),
|
||||
"an agent with skills enabled must have shell_exec even when no ready skill exists yet")
|
||||
require.False(t, toolOffered(chatModel.lastToolNames, tools.ToolReadSkill),
|
||||
"an empty sandbox must not be offered skill tools that cannot succeed")
|
||||
require.False(t, toolOffered(chatModel.lastToolNames, tools.ToolExecuteSkillScript))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolListSandboxFiles))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolReadSandboxFile))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolWriteSandboxFile))
|
||||
require.True(t, toolOffered(chatModel.lastToolNames, tools.ToolEditSandboxFile))
|
||||
require.Nil(t, engine.(*agent.AgentEngine).GetSkillsManager())
|
||||
}
|
||||
|
||||
// Whether an installed skill is invocable is decided when the AgentConfig is
|
||||
// built (effectiveTenantSkills). This is the other half of that contract: the
|
||||
// manager the model is described from must carry exactly the rows it was
|
||||
// handed, and nothing when it was handed none.
|
||||
func TestSkillsManagerOffersTheInjectedInstalledSkills(t *testing.T) {
|
||||
newSvc := func() *agentService {
|
||||
return &agentService{
|
||||
sandboxResolver: stubSandboxResolver{
|
||||
mgr: &capableManager{typ: sandbox.SandboxTypeCube, shell: &stubShellExecutor{}},
|
||||
},
|
||||
}
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(7))
|
||||
skillNamesOf := func(mgr *skills.Manager) []string {
|
||||
var names []string
|
||||
for _, meta := range mgr.GetAllMetadata() {
|
||||
names = append(names, meta.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
t.Run("injected rows are offered without a host skill directory", func(t *testing.T) {
|
||||
mgr, err := newSvc().initializeSkillsManager(ctx, "sess-1", &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-remote",
|
||||
SkillsEnabled: true,
|
||||
TenantSkills: []*types.TenantSkillEntity{{
|
||||
ID: "sk-1", TenantID: 7, SandboxConfigID: "cfg-remote",
|
||||
Name: "pdf-tools", Description: "PDF helpers",
|
||||
Status: types.SkillStatusReady, Enabled: true,
|
||||
}},
|
||||
}, tools.NewToolRegistry())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"pdf-tools"}, skillNamesOf(mgr))
|
||||
})
|
||||
|
||||
t.Run("host preloaded skills are not offered beside the sandbox image", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
skillDir := filepath.Join(dir, "document-analyzer")
|
||||
require.NoError(t, os.MkdirAll(skillDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(skillDir, "SKILL.md"),
|
||||
[]byte("---\nname: document-analyzer\ndescription: old host copy\n---\n"),
|
||||
0o644,
|
||||
))
|
||||
|
||||
mgr, err := newSvc().initializeSkillsManager(ctx, "sess-1", &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-remote",
|
||||
SkillsEnabled: true,
|
||||
SkillDirs: []string{dir},
|
||||
TenantSkills: []*types.TenantSkillEntity{{
|
||||
ID: "sk-1", TenantID: 7, SandboxConfigID: "cfg-remote",
|
||||
Name: "pdf-tools", Description: "PDF helpers",
|
||||
Status: types.SkillStatusReady, Enabled: true,
|
||||
}},
|
||||
}, tools.NewToolRegistry())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"pdf-tools"}, skillNamesOf(mgr),
|
||||
"the host skills/preloaded tree is not what the sandbox image carries")
|
||||
})
|
||||
|
||||
t.Run("injected rows are offered", func(t *testing.T) {
|
||||
mgr, err := newSvc().initializeSkillsManager(ctx, "sess-1", &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-remote",
|
||||
SkillsEnabled: true,
|
||||
SkillDirs: []string{t.TempDir()},
|
||||
TenantSkills: []*types.TenantSkillEntity{{
|
||||
ID: "sk-1", TenantID: 7, SandboxConfigID: "cfg-remote",
|
||||
Name: "pdf-tools", Description: "PDF helpers",
|
||||
Status: types.SkillStatusReady, Enabled: true,
|
||||
}},
|
||||
}, tools.NewToolRegistry())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"pdf-tools"}, skillNamesOf(mgr))
|
||||
})
|
||||
|
||||
t.Run("an unusable image injects no rows and offers no skills", func(t *testing.T) {
|
||||
mgr, err := newSvc().initializeSkillsManager(ctx, "sess-1", &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-remote",
|
||||
SkillsEnabled: true,
|
||||
SkillDirs: []string{t.TempDir()},
|
||||
}, tools.NewToolRegistry())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, skillNamesOf(mgr),
|
||||
"a skill the model is told about but cannot invoke burns turns for nothing")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillInstallerIsHiddenFromThePicker(t *testing.T) {
|
||||
require.NoError(t, types.LoadBuiltinAgentsConfig(filepath.Join("..", "..", "..", "config")))
|
||||
|
||||
require.True(t, types.IsBuiltinAgentID(types.BuiltinSkillInstallerID),
|
||||
"the server must still be able to resolve it by ID")
|
||||
require.NotContains(t, types.GetBuiltinAgentIDs(), types.BuiltinSkillInstallerID,
|
||||
"it must not clutter the tenant's agent picker")
|
||||
}
|
||||
|
||||
func TestGetKnowledgeBaseInfos_SharedKnowledgeBaseUsesSourceTenant(t *testing.T) {
|
||||
const (
|
||||
receiverTenantID = uint64(7)
|
||||
sourceTenantID = uint64(42)
|
||||
)
|
||||
now := time.Now()
|
||||
knowledgeService := &fakeAgentKnowledgeService{
|
||||
knowledges: []*types.Knowledge{
|
||||
{
|
||||
ID: "shared-doc",
|
||||
KnowledgeBaseID: "shared-kb",
|
||||
Title: "shared document",
|
||||
ParseStatus: types.ParseStatusCompleted,
|
||||
CreatedAt: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
service := &agentService{
|
||||
knowledgeBaseService: &fakeAgentKnowledgeBaseService{
|
||||
kb: &types.KnowledgeBase{
|
||||
ID: "shared-kb",
|
||||
Name: "Shared KB",
|
||||
Type: types.KnowledgeBaseTypeDocument,
|
||||
TenantID: sourceTenantID,
|
||||
},
|
||||
},
|
||||
knowledgeService: knowledgeService,
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, receiverTenantID)
|
||||
|
||||
infos, err := service.getKnowledgeBaseInfos(ctx, []string{"shared-kb"}, map[string]uint64{"shared-kb": sourceTenantID})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, infos, 1)
|
||||
assert.Equal(t, 1, infos[0].DocCount)
|
||||
require.Len(t, infos[0].RecentDocs, 1)
|
||||
assert.Equal(t, "shared-doc", infos[0].RecentDocs[0].KnowledgeID)
|
||||
assert.Equal(t, sourceTenantID, knowledgeService.lastTenant)
|
||||
}
|
||||
|
||||
func TestGetKnowledgeBaseInfos_ExcludesUnprocessedDocuments(t *testing.T) {
|
||||
now := time.Now()
|
||||
knowledgeService := &fakeAgentKnowledgeService{
|
||||
knowledges: []*types.Knowledge{
|
||||
{
|
||||
ID: "doc-processing",
|
||||
KnowledgeBaseID: "kb-1",
|
||||
Title: "still parsing",
|
||||
FileName: "processing.pdf",
|
||||
FileType: "pdf",
|
||||
ParseStatus: types.ParseStatusProcessing,
|
||||
CreatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "doc-completed",
|
||||
KnowledgeBaseID: "kb-1",
|
||||
Title: "ready document",
|
||||
FileName: "ready.pdf",
|
||||
FileType: "pdf",
|
||||
ParseStatus: types.ParseStatusCompleted,
|
||||
CreatedAt: now.Add(-time.Minute),
|
||||
},
|
||||
},
|
||||
}
|
||||
service := &agentService{
|
||||
knowledgeBaseService: &fakeAgentKnowledgeBaseService{
|
||||
kb: &types.KnowledgeBase{
|
||||
ID: "kb-1",
|
||||
Name: "KB",
|
||||
Type: types.KnowledgeBaseTypeDocument,
|
||||
TenantID: 1,
|
||||
},
|
||||
},
|
||||
knowledgeService: knowledgeService,
|
||||
}
|
||||
|
||||
infos, err := service.getKnowledgeBaseInfos(context.Background(), []string{"kb-1"}, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, infos, 1)
|
||||
assert.Equal(t, types.ParseStatusCompleted, knowledgeService.lastFilter.ParseStatus)
|
||||
assert.Equal(t, 1, infos[0].DocCount)
|
||||
require.Len(t, infos[0].RecentDocs, 1)
|
||||
assert.Equal(t, "doc-completed", infos[0].RecentDocs[0].KnowledgeID)
|
||||
}
|
||||
|
||||
func TestValidateConfigMaxIterationsUnlimited(t *testing.T) {
|
||||
s := &agentService{}
|
||||
|
||||
unlimited := &types.AgentConfig{MaxIterations: -1}
|
||||
require.NoError(t, s.ValidateConfig(unlimited))
|
||||
assert.Equal(t, types.UnlimitedMaxIterations, unlimited.MaxIterations)
|
||||
|
||||
normalized := &types.AgentConfig{MaxIterations: -9}
|
||||
require.NoError(t, s.ValidateConfig(normalized))
|
||||
assert.Equal(t, types.UnlimitedMaxIterations, normalized.MaxIterations)
|
||||
|
||||
unset := &types.AgentConfig{}
|
||||
require.NoError(t, s.ValidateConfig(unset))
|
||||
assert.Equal(t, 5, unset.MaxIterations)
|
||||
|
||||
tooHigh := &types.AgentConfig{MaxIterations: MAX_ITERATIONS + 1}
|
||||
require.Error(t, s.ValidateConfig(tooHigh))
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Without a sandbox config there is nothing to resolve against: no config-wide
|
||||
// scope to read and no installed skill to name.
|
||||
func TestAgentServiceUserEnvResolverNilWithoutASandboxConfig(t *testing.T) {
|
||||
s := &agentService{db: &gorm.DB{}}
|
||||
|
||||
require.Nil(t, s.userEnvResolver(context.Background(), &types.AgentConfig{}))
|
||||
}
|
||||
|
||||
func TestAgentServiceUserEnvResolverNilWithoutDB(t *testing.T) {
|
||||
s := &agentService{}
|
||||
|
||||
resolver := s.userEnvResolver(context.Background(), &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-1",
|
||||
TenantSkills: []*types.TenantSkillEntity{{ID: "sk-1", TenantID: 7, Name: "web-search"}},
|
||||
})
|
||||
|
||||
require.Nil(t, resolver)
|
||||
}
|
||||
|
||||
// The resolver exists even with no installed skills, so shell_exec still gets
|
||||
// the caller's config-wide variables.
|
||||
func TestAgentServiceUserEnvResolverBuiltWithoutInstalledSkills(t *testing.T) {
|
||||
s := &agentService{db: &gorm.DB{}}
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(7))
|
||||
|
||||
resolver := s.userEnvResolver(ctx, &types.AgentConfig{SandboxConfigID: "cfg-1"})
|
||||
|
||||
require.NotNil(t, resolver)
|
||||
typed, ok := resolver.(*userEnvResolver)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, uint64(7), typed.tenantID)
|
||||
require.Equal(t, "cfg-1", typed.configID)
|
||||
require.Empty(t, typed.byName)
|
||||
}
|
||||
|
||||
// The tenant id is read off the row, matching tenantSkillSource, so the lookup
|
||||
// cannot resolve into a different workspace.
|
||||
func TestAgentServiceUserEnvResolverIndexesByNameAndRowTenant(t *testing.T) {
|
||||
s := &agentService{db: &gorm.DB{}}
|
||||
|
||||
resolver := s.userEnvResolver(context.Background(), &types.AgentConfig{
|
||||
SandboxConfigID: "cfg-1",
|
||||
TenantSkills: []*types.TenantSkillEntity{
|
||||
{ID: "sk-1", TenantID: 7, Name: "web-search"},
|
||||
{ID: "sk-2", TenantID: 7, Name: "pdf"},
|
||||
},
|
||||
})
|
||||
|
||||
require.NotNil(t, resolver)
|
||||
typed, ok := resolver.(*userEnvResolver)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, uint64(7), typed.tenantID)
|
||||
require.Equal(t, "cfg-1", typed.configID)
|
||||
require.Contains(t, typed.byName, "web-search")
|
||||
require.Contains(t, typed.byName, "pdf")
|
||||
require.Equal(t, "sk-1", typed.byName["web-search"].ID)
|
||||
}
|
||||
|
||||
func TestAgentServiceSkillEnvCaptureNilWithoutASandboxConfig(t *testing.T) {
|
||||
s := &agentService{db: &gorm.DB{}}
|
||||
|
||||
require.Nil(t, s.skillEnvCapture(&types.AgentConfig{}))
|
||||
require.Nil(t, s.skillEnvCapture(nil))
|
||||
}
|
||||
|
||||
func TestAgentServiceSkillEnvCaptureNilWithoutDB(t *testing.T) {
|
||||
s := &agentService{}
|
||||
|
||||
require.Nil(t, s.skillEnvCapture(&types.AgentConfig{SandboxConfigID: "cfg-1"}))
|
||||
}
|
||||
|
||||
func TestAgentServiceSkillEnvCaptureBuiltWithConfigAndDB(t *testing.T) {
|
||||
s := &agentService{db: &gorm.DB{}}
|
||||
|
||||
require.NotNil(t, s.skillEnvCapture(&types.AgentConfig{SandboxConfigID: "cfg-1"}))
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/application/repository"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAgentShareNotFound = errors.New("agent share not found")
|
||||
ErrAgentSharePermission = errors.New("permission denied for this share operation")
|
||||
ErrAgentNotFoundForShare = errors.New("agent not found")
|
||||
ErrNotAgentOwner = errors.New("only agent owner can share")
|
||||
ErrOrgRoleCannotShareAgent = errors.New("only editors and admins can share agents to this organization")
|
||||
ErrAgentNotConfigured = errors.New("agent is not fully configured (missing required chat model, or rerank model when the knowledge_search tool is enabled)")
|
||||
)
|
||||
|
||||
// agentRequiresRerankModel returns true when the agent's configured scope and
|
||||
// tools will actually invoke the reranker at runtime. An agent whose knowledge
|
||||
// base scope is explicitly disabled cannot run knowledge_search, so it does not
|
||||
// need a rerank model even if that tool remains in AllowedTools.
|
||||
//
|
||||
// This mirrors the runtime check in session_agent_qa.go: only
|
||||
// `knowledge_search` with an enabled knowledge-base scope uses the reranker.
|
||||
// Wiki-first agents (wiki_search / wiki_read_page / …) never call it and
|
||||
// therefore don't need a rerank model configured, even when knowledge bases
|
||||
// are attached.
|
||||
//
|
||||
// When AllowedTools is empty the runtime falls back to
|
||||
// tools.DefaultAllowedTools(), which includes knowledge_search, so we treat
|
||||
// that case as requiring the reranker.
|
||||
func agentRequiresRerankModel(agent *types.CustomAgent) bool {
|
||||
if agent == nil {
|
||||
return false
|
||||
}
|
||||
if agent.Config.KBSelectionMode == "none" {
|
||||
return false
|
||||
}
|
||||
allowed := agent.Config.AllowedTools
|
||||
if len(allowed) == 0 {
|
||||
allowed = tools.DefaultAllowedTools()
|
||||
}
|
||||
for _, t := range allowed {
|
||||
if t == tools.ToolKnowledgeSearch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// agentShareService implements AgentShareService.
|
||||
//
|
||||
// Plan 3 of #1303: visibility and access checks key on the caller's
|
||||
// tenant. callerTenantRole flows through every read path so the 3-D
|
||||
// cap (tenant Viewer → at most OrgRoleViewer) lands consistently.
|
||||
type agentShareService struct {
|
||||
shareRepo interfaces.AgentShareRepository
|
||||
disabledRepo interfaces.TenantDisabledSharedAgentRepository
|
||||
orgRepo interfaces.OrganizationRepository
|
||||
agentRepo interfaces.CustomAgentRepository
|
||||
userRepo interfaces.UserRepository
|
||||
webSearchProviderRepo interfaces.WebSearchProviderRepository
|
||||
}
|
||||
|
||||
// NewAgentShareService creates a new agent share service
|
||||
func NewAgentShareService(
|
||||
shareRepo interfaces.AgentShareRepository,
|
||||
disabledRepo interfaces.TenantDisabledSharedAgentRepository,
|
||||
orgRepo interfaces.OrganizationRepository,
|
||||
agentRepo interfaces.CustomAgentRepository,
|
||||
userRepo interfaces.UserRepository,
|
||||
webSearchProviderRepo interfaces.WebSearchProviderRepository,
|
||||
) interfaces.AgentShareService {
|
||||
return &agentShareService{
|
||||
shareRepo: shareRepo,
|
||||
disabledRepo: disabledRepo,
|
||||
orgRepo: orgRepo,
|
||||
agentRepo: agentRepo,
|
||||
userRepo: userRepo,
|
||||
webSearchProviderRepo: webSearchProviderRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *agentShareService) sharedAgentInfo(
|
||||
ctx context.Context,
|
||||
share *types.AgentShare,
|
||||
effective types.OrgMemberRole,
|
||||
webSearchReadyCache map[string]bool,
|
||||
) *types.SharedAgentInfo {
|
||||
info := &types.SharedAgentInfo{
|
||||
Agent: share.Agent,
|
||||
ShareID: share.ID,
|
||||
OrganizationID: share.OrganizationID,
|
||||
Permission: effective,
|
||||
SourceTenantID: share.SourceTenantID,
|
||||
SharedAt: share.CreatedAt,
|
||||
SharedByUserID: share.SharedByUserID,
|
||||
}
|
||||
if share.Agent != nil {
|
||||
types.ApplyBuiltinAgentLocalization(ctx, share.Agent)
|
||||
}
|
||||
if share.Organization != nil {
|
||||
info.OrgName = share.Organization.Name
|
||||
}
|
||||
if share.SharedByUserID != "" && s.userRepo != nil {
|
||||
if u, err := s.userRepo.GetUserByID(ctx, share.SharedByUserID); err == nil && u != nil {
|
||||
info.SharedByUsername = u.Username
|
||||
}
|
||||
}
|
||||
cacheKey := fmt.Sprintf("%d:%t:%s", share.SourceTenantID, share.Agent.Config.WebSearchEnabled, share.Agent.Config.WebSearchProviderID)
|
||||
if ready, ok := webSearchReadyCache[cacheKey]; ok {
|
||||
info.WebSearchReady = ready
|
||||
} else {
|
||||
info.WebSearchReady = s.isAgentWebSearchReady(ctx, share.Agent, share.SourceTenantID)
|
||||
webSearchReadyCache[cacheKey] = info.WebSearchReady
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// isAgentWebSearchReady resolves only an availability bit in the source
|
||||
// workspace. Returning source provider rows would leak configuration, while
|
||||
// comparing provider IDs with the receiver workspace produces false negatives.
|
||||
func (s *agentShareService) isAgentWebSearchReady(
|
||||
ctx context.Context,
|
||||
agent *types.CustomAgent,
|
||||
sourceTenantID uint64,
|
||||
) bool {
|
||||
if agent == nil || !agent.Config.WebSearchEnabled || s.webSearchProviderRepo == nil {
|
||||
return false
|
||||
}
|
||||
var provider *types.WebSearchProviderEntity
|
||||
var err error
|
||||
if agent.Config.WebSearchProviderID != "" {
|
||||
provider, err = s.webSearchProviderRepo.GetByID(ctx, sourceTenantID, agent.Config.WebSearchProviderID)
|
||||
} else {
|
||||
provider, err = s.webSearchProviderRepo.GetDefault(ctx, sourceTenantID)
|
||||
}
|
||||
return err == nil && provider != nil
|
||||
}
|
||||
|
||||
// ShareAgent shares an agent to an organization. Permission is forced to
|
||||
// OrgRoleViewer (cross-tenant agent edit is not part of v1).
|
||||
func (s *agentShareService) ShareAgent(ctx context.Context, agentID string, orgID string, userID string, tenantID uint64, permission types.OrgMemberRole) (*types.AgentShare, error) {
|
||||
logger.Infof(ctx, "Sharing agent %s to organization %s", agentID, orgID)
|
||||
|
||||
agent, err := s.agentRepo.GetAgentByID(ctx, agentID, tenantID)
|
||||
if err != nil || agent == nil {
|
||||
return nil, ErrAgentNotFoundForShare
|
||||
}
|
||||
if agent.TenantID != tenantID {
|
||||
return nil, ErrNotAgentOwner
|
||||
}
|
||||
agent.EnsureDefaults()
|
||||
if agent.Visibility == "private" && !assetOwnedByCaller(ctx, agent.TenantID, agent.CreatedBy) {
|
||||
return nil, ErrNotAgentOwner
|
||||
}
|
||||
if guard, ok := s.agentRepo.(digitalHumanAgentAccess); ok {
|
||||
visible, err := guard.FilterDigitalHumanAgents(ctx, []*types.CustomAgent{agent})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(visible) == 0 {
|
||||
return nil, ErrNotAgentOwner
|
||||
}
|
||||
}
|
||||
|
||||
if agent.Config.ModelID == "" {
|
||||
return nil, ErrAgentNotConfigured
|
||||
}
|
||||
if agentRequiresRerankModel(agent) && agent.Config.RerankModelID == "" {
|
||||
return nil, ErrAgentNotConfigured
|
||||
}
|
||||
|
||||
_, err = s.orgRepo.GetByID(ctx, orgID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrOrganizationNotFound) {
|
||||
return nil, ErrOrgNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Caller's tenant must be an org member with editor+ role to share.
|
||||
tm, err := s.orgRepo.GetTenantMember(ctx, orgID, tenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrOrgMemberNotFound) {
|
||||
return nil, ErrTenantNotInOrg
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !tm.Role.HasPermission(types.OrgRoleEditor) {
|
||||
return nil, ErrOrgRoleCannotShareAgent
|
||||
}
|
||||
|
||||
// 智能体共享仅支持只读
|
||||
permission = types.OrgRoleViewer
|
||||
|
||||
share := &types.AgentShare{
|
||||
ID: uuid.New().String(),
|
||||
AgentID: agentID,
|
||||
OrganizationID: orgID,
|
||||
SharedByUserID: userID,
|
||||
SourceTenantID: tenantID,
|
||||
Permission: permission,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.shareRepo.Create(ctx, share); err != nil {
|
||||
if errors.Is(err, repository.ErrAgentShareAlreadyExists) {
|
||||
existing, err := s.shareRepo.GetByAgentAndOrg(ctx, agentID, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing.Permission = types.OrgRoleViewer
|
||||
existing.UpdatedAt = time.Now()
|
||||
if err := s.shareRepo.Update(ctx, existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Agent %s shared successfully to organization %s", agentID, orgID)
|
||||
return share, nil
|
||||
}
|
||||
|
||||
// RemoveShare removes an agent share.
|
||||
// Same authz envelope as KB-share remove (see kbshare.callerCanManageShare):
|
||||
// original sharer, OR source-tenant Admin+, OR target-org admin.
|
||||
func (s *agentShareService) RemoveShare(ctx context.Context, shareID string, userID string, tenantID uint64) error {
|
||||
share, err := s.shareRepo.GetByID(ctx, shareID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAgentShareNotFound) {
|
||||
return ErrAgentShareNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
// (1) Original sharer.
|
||||
if share.SharedByUserID == userID {
|
||||
return s.shareRepo.Delete(ctx, shareID)
|
||||
}
|
||||
// (2) Source-tenant Admin+ — Plan 3 ownership is tenant-level.
|
||||
if tenantID != 0 && tenantID == share.SourceTenantID {
|
||||
if types.TenantRoleFromContext(ctx).HasPermission(types.TenantRoleAdmin) {
|
||||
return s.shareRepo.Delete(ctx, shareID)
|
||||
}
|
||||
}
|
||||
// (3) Org admin in the target org (governance / sharer-left repair).
|
||||
if tm, err := s.orgRepo.GetTenantMember(ctx, share.OrganizationID, tenantID); err == nil && tm.Role == types.OrgRoleAdmin {
|
||||
return s.shareRepo.Delete(ctx, shareID)
|
||||
}
|
||||
return ErrAgentSharePermission
|
||||
}
|
||||
|
||||
// ListSharesByAgent lists all shares for an agent owned by tenantID.
|
||||
//
|
||||
// Ownership is enforced here (not only at the route layer): the caller must
|
||||
// own the agent, mirroring ListSharesByKnowledgeBase. This is the sole guard
|
||||
// for API-key principals, whose route-level OwnedAgentOrAdmin check
|
||||
// short-circuits — without this, a full-access key could enumerate any
|
||||
// tenant's agent shares by ID (cross-tenant IDOR).
|
||||
func (s *agentShareService) ListSharesByAgent(
|
||||
ctx context.Context, agentID string, tenantID uint64,
|
||||
) ([]*types.AgentShare, error) {
|
||||
agent, err := s.agentRepo.GetAgentByID(ctx, agentID, tenantID)
|
||||
if err != nil || agent == nil {
|
||||
return nil, ErrAgentNotFoundForShare
|
||||
}
|
||||
if agent.TenantID != tenantID {
|
||||
return nil, ErrNotAgentOwner
|
||||
}
|
||||
return s.shareRepo.ListByAgent(ctx, agentID)
|
||||
}
|
||||
|
||||
// ListSharesByOrganization lists all agent shares for an organization
|
||||
func (s *agentShareService) ListSharesByOrganization(ctx context.Context, orgID string) ([]*types.AgentShare, error) {
|
||||
return s.shareRepo.ListByOrganization(ctx, orgID)
|
||||
}
|
||||
|
||||
// ListSharedAgents lists agents reachable from the caller's tenant.
|
||||
func (s *agentShareService) ListSharedAgents(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole) ([]*types.SharedAgentInfo, error) {
|
||||
shares, err := s.shareRepo.ListSharedAgentsForTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
agentInfoMap := make(map[string]*types.SharedAgentInfo)
|
||||
webSearchReadyCache := make(map[string]bool)
|
||||
for _, share := range shares {
|
||||
if share.SourceTenantID == tenantID {
|
||||
continue
|
||||
}
|
||||
if share.Agent == nil {
|
||||
continue
|
||||
}
|
||||
tm, err := s.orgRepo.GetTenantMember(ctx, share.OrganizationID, tenantID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
effective := types.MinOrgRole(share.Permission, tm.Role)
|
||||
effective = applyTenantRoleCap(effective, callerTenantRole)
|
||||
info := s.sharedAgentInfo(ctx, share, effective, webSearchReadyCache)
|
||||
key := fmt.Sprintf("%s_%d", share.AgentID, share.SourceTenantID)
|
||||
existing, exists := agentInfoMap[key]
|
||||
if !exists {
|
||||
agentInfoMap[key] = info
|
||||
} else if effective.HasPermission(existing.Permission) && effective != existing.Permission {
|
||||
agentInfoMap[key] = info
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]*types.SharedAgentInfo, 0, len(agentInfoMap))
|
||||
for _, info := range agentInfoMap {
|
||||
result = append(result, info)
|
||||
}
|
||||
|
||||
// Set DisabledByMe from tenant_disabled_shared_agents for current tenant
|
||||
disabledList, err := s.disabledRepo.ListByTenantID(ctx, tenantID)
|
||||
if err != nil {
|
||||
return result, nil
|
||||
}
|
||||
disabledSet := make(map[string]bool)
|
||||
for _, d := range disabledList {
|
||||
disabledSet[fmt.Sprintf("%s_%d", d.AgentID, d.SourceTenantID)] = true
|
||||
}
|
||||
for _, info := range result {
|
||||
if info.Agent != nil {
|
||||
key := fmt.Sprintf("%s_%d", info.Agent.ID, info.SourceTenantID)
|
||||
info.DisabledByMe = disabledSet[key]
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListSharedAgentsInOrganization returns all agents shared to the given
|
||||
// organization (including those shared by the caller's tenant), for list-page
|
||||
// display when a space is selected.
|
||||
func (s *agentShareService) ListSharedAgentsInOrganization(ctx context.Context, orgID string, tenantID uint64, callerTenantRole types.TenantRole) ([]*types.OrganizationSharedAgentItem, error) {
|
||||
tm, err := s.orgRepo.GetTenantMember(ctx, orgID, tenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrOrgMemberNotFound) {
|
||||
return nil, ErrTenantNotInOrg
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shares, err := s.shareRepo.ListByOrganization(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*types.OrganizationSharedAgentItem, 0, len(shares))
|
||||
webSearchReadyCache := make(map[string]bool)
|
||||
for _, share := range shares {
|
||||
if share.Agent == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
effective := types.MinOrgRole(share.Permission, tm.Role)
|
||||
effective = applyTenantRoleCap(effective, callerTenantRole)
|
||||
|
||||
info := s.sharedAgentInfo(ctx, share, effective, webSearchReadyCache)
|
||||
|
||||
item := &types.OrganizationSharedAgentItem{
|
||||
SharedAgentInfo: *info,
|
||||
IsMine: share.SourceTenantID == tenantID,
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
disabledList, err := s.disabledRepo.ListByTenantID(ctx, tenantID)
|
||||
if err == nil {
|
||||
disabledSet := make(map[string]bool)
|
||||
for _, d := range disabledList {
|
||||
disabledSet[fmt.Sprintf("%s_%d", d.AgentID, d.SourceTenantID)] = true
|
||||
}
|
||||
for _, item := range result {
|
||||
if item.Agent != nil && !item.IsMine {
|
||||
key := fmt.Sprintf("%s_%d", item.Agent.ID, item.SourceTenantID)
|
||||
item.DisabledByMe = disabledSet[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListSharedAgentsInOrganizations returns per-org agent lists (batch); only
|
||||
// orgs where the caller's tenant is a member.
|
||||
func (s *agentShareService) ListSharedAgentsInOrganizations(ctx context.Context, orgIDs []string, tenantID uint64, callerTenantRole types.TenantRole) (map[string][]*types.OrganizationSharedAgentItem, error) {
|
||||
out := make(map[string][]*types.OrganizationSharedAgentItem)
|
||||
if len(orgIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
members, err := s.orgRepo.ListTenantMembersByTenantForOrgs(ctx, tenantID, orgIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shares, err := s.shareRepo.ListByOrganizations(ctx, orgIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byOrg := make(map[string][]*types.AgentShare)
|
||||
for _, share := range shares {
|
||||
if share != nil && members[share.OrganizationID] != nil {
|
||||
byOrg[share.OrganizationID] = append(byOrg[share.OrganizationID], share)
|
||||
}
|
||||
}
|
||||
disabledSet := make(map[string]bool)
|
||||
if disabledList, err := s.disabledRepo.ListByTenantID(ctx, tenantID); err == nil {
|
||||
for _, d := range disabledList {
|
||||
disabledSet[fmt.Sprintf("%s_%d", d.AgentID, d.SourceTenantID)] = true
|
||||
}
|
||||
}
|
||||
webSearchReadyCache := make(map[string]bool)
|
||||
for orgID, list := range byOrg {
|
||||
tm := members[orgID]
|
||||
result := make([]*types.OrganizationSharedAgentItem, 0, len(list))
|
||||
for _, share := range list {
|
||||
if share.Agent == nil {
|
||||
continue
|
||||
}
|
||||
effective := types.MinOrgRole(share.Permission, tm.Role)
|
||||
effective = applyTenantRoleCap(effective, callerTenantRole)
|
||||
info := s.sharedAgentInfo(ctx, share, effective, webSearchReadyCache)
|
||||
item := &types.OrganizationSharedAgentItem{
|
||||
SharedAgentInfo: *info,
|
||||
IsMine: share.SourceTenantID == tenantID,
|
||||
}
|
||||
if item.Agent != nil && !item.IsMine {
|
||||
item.DisabledByMe = disabledSet[fmt.Sprintf("%s_%d", item.Agent.ID, item.SourceTenantID)]
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
out[orgID] = result
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CountByOrganizations returns share counts per organization (for list sidebar); excludes deleted agents
|
||||
func (s *agentShareService) CountByOrganizations(ctx context.Context, orgIDs []string) (map[string]int64, error) {
|
||||
return s.shareRepo.CountByOrganizations(ctx, orgIDs)
|
||||
}
|
||||
|
||||
// SetSharedAgentDisabledByMe adds or removes (tenantID, agentID, sourceTenantID) from tenant_disabled_shared_agents.
|
||||
func (s *agentShareService) SetSharedAgentDisabledByMe(ctx context.Context, tenantID uint64, agentID string, sourceTenantID uint64, disabled bool) error {
|
||||
if disabled {
|
||||
return s.disabledRepo.Add(ctx, tenantID, agentID, sourceTenantID)
|
||||
}
|
||||
return s.disabledRepo.Remove(ctx, tenantID, agentID, sourceTenantID)
|
||||
}
|
||||
|
||||
// GetSharedAgentForTenant returns the shared agent by agentID if the caller's
|
||||
// tenant has access; source tenant is resolved from the share. One share
|
||||
// lookup + one agent lookup.
|
||||
//
|
||||
// callerTenantRole is currently only used for symmetry / future caps on
|
||||
// agent execution (e.g. tenant Viewers might be banned from
|
||||
// state-changing tool calls in a follow-up).
|
||||
func (s *agentShareService) GetSharedAgentForTenant(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
callerTenantRole types.TenantRole,
|
||||
agentID string,
|
||||
sourceTenantID ...uint64,
|
||||
) (*types.CustomAgent, error) {
|
||||
if agentID == "" {
|
||||
return nil, ErrAgentShareNotFound
|
||||
}
|
||||
if len(sourceTenantID) > 0 && sourceTenantID[0] != 0 {
|
||||
if sourceTenantID[0] == tenantID {
|
||||
return nil, ErrAgentShareNotFound
|
||||
}
|
||||
share, err := s.shareRepo.GetShareByAgentIDAndSourceForTenant(ctx, tenantID, agentID, sourceTenantID[0])
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAgentShareNotFound) {
|
||||
return nil, ErrAgentSharePermission
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
agent, err := s.agentRepo.GetAgentByID(ctx, agentID, share.SourceTenantID)
|
||||
if err != nil || agent == nil {
|
||||
return nil, ErrAgentNotFoundForShare
|
||||
}
|
||||
types.ApplyBuiltinAgentLocalization(ctx, agent)
|
||||
_ = callerTenantRole
|
||||
return agent, nil
|
||||
}
|
||||
share, err := s.shareRepo.GetShareByAgentIDForTenant(ctx, tenantID, agentID, tenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAgentShareNotFound) {
|
||||
return nil, ErrAgentSharePermission
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
agent, err := s.agentRepo.GetAgentByID(ctx, agentID, share.SourceTenantID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrCustomAgentNotFound) {
|
||||
return nil, ErrAgentNotFoundForShare
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
types.ApplyBuiltinAgentLocalization(ctx, agent)
|
||||
_ = callerTenantRole
|
||||
return agent, nil
|
||||
}
|
||||
|
||||
// TenantCanAccessKBViaSomeSharedAgent returns true if the caller's tenant has
|
||||
// at least one shared agent that can access the given KB (used when opening KB
|
||||
// detail from "通过智能体可见" list without agent_id).
|
||||
func (s *agentShareService) TenantCanAccessKBViaSomeSharedAgent(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole, kb *types.KnowledgeBase) (bool, error) {
|
||||
if kb == nil || kb.ID == "" {
|
||||
return false, nil
|
||||
}
|
||||
list, err := s.ListSharedAgents(ctx, tenantID, callerTenantRole)
|
||||
if err != nil || len(list) == 0 {
|
||||
return false, err
|
||||
}
|
||||
for _, info := range list {
|
||||
if info.Agent == nil {
|
||||
continue
|
||||
}
|
||||
agent := info.Agent
|
||||
if agent.TenantID != kb.TenantID {
|
||||
continue
|
||||
}
|
||||
mode := agent.Config.KBSelectionMode
|
||||
if mode == "none" {
|
||||
continue
|
||||
}
|
||||
if mode == "all" {
|
||||
return true, nil
|
||||
}
|
||||
if mode == "selected" {
|
||||
for _, id := range agent.Config.KnowledgeBases {
|
||||
if id == kb.ID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// GetShare gets an agent share by ID
|
||||
func (s *agentShareService) GetShare(ctx context.Context, shareID string) (*types.AgentShare, error) {
|
||||
share, err := s.shareRepo.GetByID(ctx, shareID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAgentShareNotFound) {
|
||||
return nil, ErrAgentShareNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return share, nil
|
||||
}
|
||||
|
||||
// GetShareByAgentAndOrg gets an agent share by agent ID and organization ID
|
||||
func (s *agentShareService) GetShareByAgentAndOrg(ctx context.Context, agentID string, orgID string) (*types.AgentShare, error) {
|
||||
share, err := s.shareRepo.GetByAgentAndOrg(ctx, agentID, orgID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAgentShareNotFound) {
|
||||
return nil, ErrAgentShareNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return share, nil
|
||||
}
|
||||
|
||||
// GetShareByAgentIDForTenant returns one share for the given agentID that the
|
||||
// tenant can reach, excluding source_tenant_id == excludeTenantID.
|
||||
func (s *agentShareService) GetShareByAgentIDForTenant(ctx context.Context, tenantID uint64, agentID string, excludeTenantID uint64) (*types.AgentShare, error) {
|
||||
return s.shareRepo.GetShareByAgentIDForTenant(ctx, tenantID, agentID, excludeTenantID)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type sharedAgentWebSearchRepo struct {
|
||||
byIDTenant uint64
|
||||
byID string
|
||||
defaultTenant uint64
|
||||
explicit *types.WebSearchProviderEntity
|
||||
defaultProvider *types.WebSearchProviderEntity
|
||||
}
|
||||
|
||||
func (r *sharedAgentWebSearchRepo) Create(context.Context, *types.WebSearchProviderEntity) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sharedAgentWebSearchRepo) GetByID(_ context.Context, tenantID uint64, id string) (*types.WebSearchProviderEntity, error) {
|
||||
r.byIDTenant = tenantID
|
||||
r.byID = id
|
||||
return r.explicit, nil
|
||||
}
|
||||
|
||||
func (r *sharedAgentWebSearchRepo) GetDefault(_ context.Context, tenantID uint64) (*types.WebSearchProviderEntity, error) {
|
||||
r.defaultTenant = tenantID
|
||||
return r.defaultProvider, nil
|
||||
}
|
||||
|
||||
func (r *sharedAgentWebSearchRepo) List(context.Context, uint64) ([]*types.WebSearchProviderEntity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *sharedAgentWebSearchRepo) Update(context.Context, *types.WebSearchProviderEntity) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sharedAgentWebSearchRepo) Delete(context.Context, uint64, string) error { return nil }
|
||||
|
||||
func (r *sharedAgentWebSearchRepo) ClearDefault(context.Context, uint64, string) error { return nil }
|
||||
|
||||
func TestSharedAgentWebSearchReadyUsesSourceWorkspace(t *testing.T) {
|
||||
repo := &sharedAgentWebSearchRepo{
|
||||
explicit: &types.WebSearchProviderEntity{ID: "source-provider", TenantID: 42},
|
||||
}
|
||||
svc := &agentShareService{webSearchProviderRepo: repo}
|
||||
agent := &types.CustomAgent{Config: types.CustomAgentConfig{
|
||||
WebSearchEnabled: true,
|
||||
WebSearchProviderID: "source-provider",
|
||||
}}
|
||||
|
||||
require.True(t, svc.isAgentWebSearchReady(context.Background(), agent, 42))
|
||||
require.Equal(t, uint64(42), repo.byIDTenant)
|
||||
require.Equal(t, "source-provider", repo.byID)
|
||||
}
|
||||
|
||||
func TestSharedAgentWebSearchReadyUsesSourceDefault(t *testing.T) {
|
||||
repo := &sharedAgentWebSearchRepo{
|
||||
defaultProvider: &types.WebSearchProviderEntity{ID: "source-default", TenantID: 42, IsDefault: true},
|
||||
}
|
||||
svc := &agentShareService{webSearchProviderRepo: repo}
|
||||
agent := &types.CustomAgent{Config: types.CustomAgentConfig{WebSearchEnabled: true}}
|
||||
|
||||
require.True(t, svc.isAgentWebSearchReady(context.Background(), agent, 42))
|
||||
require.Equal(t, uint64(42), repo.defaultTenant)
|
||||
}
|
||||
|
||||
func TestFilterSharedAgentWriteTools(t *testing.T) {
|
||||
got := filterSharedAgentWriteTools([]string{
|
||||
tools.ToolWikiReadPage,
|
||||
tools.ToolWikiFlagIssue,
|
||||
tools.ToolWikiWritePage,
|
||||
tools.ToolWikiReplaceText,
|
||||
tools.ToolWikiRenamePage,
|
||||
tools.ToolWikiDeletePage,
|
||||
tools.ToolWikiReadIssue,
|
||||
tools.ToolWikiUpdateIssue,
|
||||
tools.ToolWebSearch,
|
||||
})
|
||||
|
||||
require.Equal(t, []string{
|
||||
tools.ToolWikiReadPage,
|
||||
tools.ToolWikiReadIssue,
|
||||
tools.ToolWebSearch,
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestFilterSharedAgentWriteToolsCoversAllWikiMutations(t *testing.T) {
|
||||
readOnlyWikiTools := map[string]bool{
|
||||
tools.ToolWikiReadPage: true,
|
||||
tools.ToolWikiSearch: true,
|
||||
tools.ToolWikiReadSourceDoc: true,
|
||||
tools.ToolWikiReadIssue: true,
|
||||
}
|
||||
|
||||
for _, definition := range tools.AvailableToolDefinitions() {
|
||||
if !strings.HasPrefix(definition.Name, "wiki_") {
|
||||
continue
|
||||
}
|
||||
filtered := filterSharedAgentWriteTools([]string{definition.Name})
|
||||
if readOnlyWikiTools[definition.Name] {
|
||||
require.Equal(t, []string{definition.Name}, filtered, "read-only wiki tool %q should remain available", definition.Name)
|
||||
continue
|
||||
}
|
||||
require.Empty(t, filtered, "wiki mutation tool %q must be filtered for shared agents", definition.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedAgentInfoLocalizesBuiltinName(t *testing.T) {
|
||||
restore := types.OverrideBuiltinAgentEntriesForTest(map[string]*types.BuiltinAgentEntry{
|
||||
types.BuiltinQuickAnswerID: {
|
||||
ID: types.BuiltinQuickAnswerID,
|
||||
I18n: map[string]types.BuiltinAgentI18n{
|
||||
"default": {Name: "快速问答", Description: "中文 RAG"},
|
||||
"en-US": {Name: "Quick Answer", Description: "English RAG"},
|
||||
},
|
||||
},
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
agent := &types.CustomAgent{
|
||||
ID: types.BuiltinQuickAnswerID,
|
||||
Name: "frozen-zh",
|
||||
Description: "frozen-zh-desc",
|
||||
IsBuiltin: true,
|
||||
TenantID: 1,
|
||||
}
|
||||
svc := &agentShareService{}
|
||||
ctx := context.WithValue(context.Background(), types.LanguageContextKey, "en-US")
|
||||
info := svc.sharedAgentInfo(ctx, &types.AgentShare{
|
||||
Agent: agent,
|
||||
SourceTenantID: 1,
|
||||
}, types.OrgRoleViewer, map[string]bool{})
|
||||
|
||||
require.Equal(t, "Quick Answer", info.Agent.Name)
|
||||
require.Equal(t, "English RAG", info.Agent.Description)
|
||||
}
|
||||
|
||||
func TestSharedAgentInfoLeavesCustomAgentName(t *testing.T) {
|
||||
agent := &types.CustomAgent{ID: "custom-1", Name: "Mine", Description: "keep me"}
|
||||
svc := &agentShareService{}
|
||||
info := svc.sharedAgentInfo(context.Background(), &types.AgentShare{
|
||||
Agent: agent,
|
||||
SourceTenantID: 1,
|
||||
}, types.OrgRoleViewer, map[string]bool{})
|
||||
require.Equal(t, "Mine", info.Agent.Name)
|
||||
require.Equal(t, "keep me", info.Agent.Description)
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
// Package service - artifact collector.
|
||||
//
|
||||
// ArtifactCollector drains a session sandbox's output directory into the
|
||||
// tenant file service after a skill turn finishes. It is intentionally kept
|
||||
// stateless and testable: a narrow SandboxArtifactSource interface abstracts
|
||||
// the sandbox side so unit tests can stub in a fake filesystem, and the
|
||||
// file service is passed in so blobs land in the same storage backend the
|
||||
// tenant already uses for uploaded attachments.
|
||||
//
|
||||
// Contract:
|
||||
// - Never delete files from the sandbox — skills can share files across
|
||||
// turns; deletion would break that (spec §2, "不清空输出目录").
|
||||
// - Never lazy-create a sandbox: the collector reads from an already-live
|
||||
// sandbox and returns an empty slice when none exists.
|
||||
// - Best-effort: individual errors are logged and skipped, never returned,
|
||||
// so a stray unreadable file cannot block the assistant reply.
|
||||
// - De-duplication by (SourcePath, ModTime): if a prior message in the
|
||||
// same session already recorded the same (path, mtime), skip it.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/sandbox"
|
||||
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SandboxArtifactSource is the narrow subset of sandbox behaviour the
|
||||
// collector needs. SessionBoundManager satisfies it in production; tests
|
||||
// use a fake to drive the diff logic without a real sandbox.
|
||||
type SandboxArtifactSource interface {
|
||||
ListSessionFiles(ctx context.Context, sessionID, dir string) ([]sandbox.RemoteDirEntry, error)
|
||||
ReadSessionFile(ctx context.Context, sessionID, path string) ([]byte, error)
|
||||
}
|
||||
|
||||
// SessionArtifactStore is the minimal repository surface the collector needs
|
||||
// to compute the "already recorded" set for a session. In production it is
|
||||
// backed by the message repository; in tests it is stubbed with an in-memory
|
||||
// map so the collector can be exercised without a database.
|
||||
type SessionArtifactStore interface {
|
||||
// KnownArtifacts returns every (SourcePath, ModTime) pair already
|
||||
// attached to any prior message of the session. The returned set is
|
||||
// unordered and safe to mutate by the caller.
|
||||
KnownArtifacts(ctx context.Context, sessionID string) ([]types.MessageArtifact, error)
|
||||
}
|
||||
|
||||
// ArtifactCollectorConfig bounds the collector's I/O and storage footprint.
|
||||
// All fields have safe zero-value defaults applied by newBoundedConfig.
|
||||
type ArtifactCollectorConfig struct {
|
||||
// OutputDir is the absolute path inside the sandbox to scan. Empty
|
||||
// falls back to skills.ArtifactOutputDir() at call time.
|
||||
OutputDir string
|
||||
|
||||
// MaxFileBytes is the largest single file that will be persisted.
|
||||
// Files larger than this are logged and skipped so a runaway skill
|
||||
// cannot exhaust the WeKnora process's memory.
|
||||
MaxFileBytes int64
|
||||
}
|
||||
|
||||
// defaultMaxArtifactFileBytes caps a single artifact at 50 MiB. Larger files
|
||||
// stream from the sandbox in a single ReadFile call today, so we cap here to
|
||||
// protect the process from OOM. The cap can be raised via config once the
|
||||
// sandbox client learns to stream to disk.
|
||||
const defaultMaxArtifactFileBytes int64 = 50 * 1024 * 1024
|
||||
|
||||
// Resource binding coordinates for collected artifacts. The owner is the
|
||||
// assistant message that produced the file (mirrors how knowledge uploads
|
||||
// bind to "knowledge" and chat attachments bind to "temporary_document"),
|
||||
// so the resource registry can enumerate / garbage-collect artifacts by
|
||||
// their owning message instead of only via the messages.artifacts JSONB.
|
||||
const (
|
||||
artifactBindingOwnerType = types.ResourceOwnerMessage
|
||||
artifactBindingRelation = types.ResourceRelationArtifact
|
||||
)
|
||||
|
||||
// ArtifactCollector implements the "drain sandbox artifacts on turn
|
||||
// completion" step described in
|
||||
// docs/superpowers/specs/2026-07-10-skill-artifact-download-design.md §4.
|
||||
type ArtifactCollector struct {
|
||||
source SandboxArtifactSource
|
||||
fileService interfaces.FileService
|
||||
store SessionArtifactStore
|
||||
// catalog binds each persisted artifact resource to its owning message.
|
||||
// Optional: nil when the deployment runs without a resource registry, in
|
||||
// which case artifacts are still saved and downloadable — they just are
|
||||
// not tracked as owned resources.
|
||||
catalog interfaces.ResourceCatalog
|
||||
config ArtifactCollectorConfig
|
||||
// resolver lets Collect use the workspace's own sandbox backend. Optional:
|
||||
// nil keeps the process-wide source for every workspace.
|
||||
resolver sandbox.TenantSandboxResolver
|
||||
pinner *SessionSandboxPinner
|
||||
// fallbackMgr is the deployment-wide SessionBoundManager. Sentinel pins
|
||||
// ("-") resolve to it rather than a per-config manager.
|
||||
fallbackMgr sandbox.Manager
|
||||
}
|
||||
|
||||
// NewArtifactCollector wires up an ArtifactCollector. Callers keep a single
|
||||
// instance per process; the collector holds no per-turn state. catalog may be
|
||||
// nil (resource registry disabled); binding is then skipped.
|
||||
func NewArtifactCollector(
|
||||
source SandboxArtifactSource,
|
||||
fileService interfaces.FileService,
|
||||
store SessionArtifactStore,
|
||||
catalog interfaces.ResourceCatalog,
|
||||
config ArtifactCollectorConfig,
|
||||
) *ArtifactCollector {
|
||||
return &ArtifactCollector{
|
||||
source: source,
|
||||
fileService: fileService,
|
||||
store: store,
|
||||
catalog: catalog,
|
||||
config: newBoundedConfig(config),
|
||||
}
|
||||
}
|
||||
|
||||
// NewArtifactCollectorFromSandboxManager is the DI-friendly constructor
|
||||
// that promotes a sandbox.Manager to SandboxArtifactSource only when the
|
||||
// underlying implementation actually supports per-session file inspection
|
||||
// (currently *sandbox.SessionBoundManager). For any other backend the
|
||||
// returned *ArtifactCollector is nil, which the AgentStreamHandler treats
|
||||
// as "no artifacts to attach" — matching the graceful-degradation contract
|
||||
// documented in the design spec.
|
||||
// The resolver is consulted per turn so a workspace whose own backend supports
|
||||
// artifacts still gets them even when the process-wide default does not. The
|
||||
// collector is therefore built whenever either side could supply a source, and
|
||||
// Collect degrades to "nothing to attach" when neither does.
|
||||
func NewArtifactCollectorFromSandboxManager(
|
||||
sandboxMgr sandbox.Manager,
|
||||
sandboxResolver sandbox.TenantSandboxResolver,
|
||||
pinner *SessionSandboxPinner,
|
||||
fileService interfaces.FileService,
|
||||
repo interfaces.MessageRepository,
|
||||
catalog interfaces.ResourceCatalog,
|
||||
) *ArtifactCollector {
|
||||
if fileService == nil {
|
||||
return nil
|
||||
}
|
||||
source, _ := sandboxMgr.(SandboxArtifactSource)
|
||||
if source == nil && sandboxResolver == nil {
|
||||
return nil
|
||||
}
|
||||
collector := NewArtifactCollector(
|
||||
source,
|
||||
fileService,
|
||||
NewMessageRepoArtifactStore(repo),
|
||||
catalog,
|
||||
ArtifactCollectorConfig{},
|
||||
)
|
||||
collector.resolver = sandboxResolver
|
||||
collector.pinner = pinner
|
||||
collector.fallbackMgr = sandboxMgr
|
||||
return collector
|
||||
}
|
||||
|
||||
// sessionSource returns the artifact source for the sandbox pinned to
|
||||
// sessionID, never the one the agent points at today: the sandbox being drained
|
||||
// was created earlier and may live on a config the agent no longer selects.
|
||||
//
|
||||
// Returns nil when the session has no pin, which Collect treats as "nothing to
|
||||
// attach".
|
||||
func (c *ArtifactCollector) sessionSource(ctx context.Context, sessionID string) SandboxArtifactSource {
|
||||
if c.resolver == nil {
|
||||
return c.source
|
||||
}
|
||||
tenantID, _ := types.TenantIDFromContext(ctx)
|
||||
configID, err := sandboxConfigForExistingSandbox(ctx, c.pinner, sessionID)
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "[ArtifactCollector] read sandbox pin failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
if configID == "" {
|
||||
return nil
|
||||
}
|
||||
mgr, err := resolveTenantSandboxForConfig(
|
||||
ctx, c.resolver, c.fallbackMgr, tenantID, configID, nil,
|
||||
)
|
||||
if err != nil {
|
||||
// Refusing to read is the safe failure: substituting another backend
|
||||
// would look in the wrong provider account and report "no artifacts".
|
||||
logger.Warnf(ctx, "[ArtifactCollector] resolve sandbox failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
if mgr == nil {
|
||||
// The pin names the deployment-wide default, which has no per-config
|
||||
// manager of its own; the injected process-wide source IS that backend.
|
||||
return c.source
|
||||
}
|
||||
if source, ok := mgr.(SandboxArtifactSource); ok {
|
||||
return source
|
||||
}
|
||||
if configID == types.SandboxConfigIDGlobalDefault {
|
||||
return c.source
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newBoundedConfig fills in defaults so callers can pass a zero
|
||||
// ArtifactCollectorConfig without hitting empty-value edge cases.
|
||||
func newBoundedConfig(cfg ArtifactCollectorConfig) ArtifactCollectorConfig {
|
||||
if cfg.MaxFileBytes <= 0 {
|
||||
cfg.MaxFileBytes = defaultMaxArtifactFileBytes
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Collect scans the session sandbox's output directory and persists any
|
||||
// newly-created or newly-modified files to the tenant file service.
|
||||
//
|
||||
// The returned slice contains one MessageArtifact per persisted file. When
|
||||
// no sandbox is bound to the session, or when the source is nil (skill
|
||||
// backend disabled), Collect returns nil, nil — the caller is expected to
|
||||
// treat both cases as "nothing to attach" and NOT set message.Artifacts.
|
||||
//
|
||||
// Errors returned by Collect are limited to internal invariants (nil
|
||||
// dependencies). Per-file errors (unreadable, too-large, upload failure)
|
||||
// are logged and skipped so a single misbehaving artifact never breaks the
|
||||
// turn.
|
||||
func (c *ArtifactCollector) Collect(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
messageID string,
|
||||
tenantID uint64,
|
||||
outputDir string,
|
||||
) (types.MessageArtifacts, error) {
|
||||
return c.collect(ctx, sessionID, messageID, tenantID, outputDir, nil)
|
||||
}
|
||||
|
||||
// CollectWithNotify is Collect plus a progress hook fired after the sandbox
|
||||
// listing is filtered, before any file is read or uploaded. The frontend uses
|
||||
// this to show a toolbar placeholder while object-storage uploads (often a
|
||||
// few seconds for HTML charts) finish. notify is skipped when nothing will
|
||||
// be persisted, so ordinary sandbox turns without new files stay quiet.
|
||||
func (c *ArtifactCollector) CollectWithNotify(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
messageID string,
|
||||
tenantID uint64,
|
||||
outputDir string,
|
||||
notify func(pending int),
|
||||
) (types.MessageArtifacts, error) {
|
||||
return c.collect(ctx, sessionID, messageID, tenantID, outputDir, notify)
|
||||
}
|
||||
|
||||
func (c *ArtifactCollector) collect(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
messageID string,
|
||||
tenantID uint64,
|
||||
outputDir string,
|
||||
notify func(pending int),
|
||||
) (artifacts types.MessageArtifacts, err error) {
|
||||
if c == nil || c.fileService == nil {
|
||||
logger.Infof(ctx, "[ArtifactCollector] skipped: collector or dependencies nil (session=%s)", sessionID)
|
||||
return nil, nil
|
||||
}
|
||||
source := c.sessionSource(ctx, sessionID)
|
||||
if source == nil {
|
||||
logger.Infof(ctx,
|
||||
"[ArtifactCollector] skipped: sandbox backend has no session filesystem (session=%s)",
|
||||
sessionID)
|
||||
return nil, nil
|
||||
}
|
||||
if sessionID == "" {
|
||||
logger.Infof(ctx, "[ArtifactCollector] skipped: empty sessionID")
|
||||
return nil, nil
|
||||
}
|
||||
if outputDir == "" {
|
||||
outputDir = c.config.OutputDir
|
||||
}
|
||||
if outputDir == "" {
|
||||
// Callers should have resolved this via skills.ArtifactOutputDir
|
||||
// but we guard here anyway to keep Collect self-contained.
|
||||
logger.Infof(ctx, "[ArtifactCollector] skipped: empty outputDir (session=%s)", sessionID)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx, span := langfuse.GetManager().StartSpan(ctx, langfuse.SpanOptions{
|
||||
Name: "sandbox.collect_artifacts",
|
||||
Input: map[string]interface{}{
|
||||
"session_id": sessionID,
|
||||
"message_id": messageID,
|
||||
"output_dir": outputDir,
|
||||
},
|
||||
})
|
||||
defer func() {
|
||||
span.Finish(map[string]interface{}{
|
||||
"artifact_count": len(artifacts),
|
||||
}, nil, err)
|
||||
}()
|
||||
|
||||
logger.Infof(ctx, "[ArtifactCollector] begin session=%s dir=%s", sessionID, outputDir)
|
||||
|
||||
entries, err := source.ListSessionFiles(ctx, sessionID, outputDir)
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "[ArtifactCollector] list sandbox files failed: session=%s dir=%s err=%v",
|
||||
sessionID, outputDir, err)
|
||||
return nil, nil
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
// The most common cause of "download button never appears" is
|
||||
// exactly this branch: either the sandbox was already reaped or
|
||||
// the skill wrote to a different directory. Logging the exact
|
||||
// (session, dir) pair makes it a 30-second grep to confirm.
|
||||
logger.Infof(ctx, "[ArtifactCollector] no entries under %s (session=%s) — sandbox reaped or skill wrote elsewhere",
|
||||
outputDir, sessionID)
|
||||
return nil, nil
|
||||
}
|
||||
logger.Infof(ctx, "[ArtifactCollector] listed %d entries under %s (session=%s)", len(entries), outputDir, sessionID)
|
||||
|
||||
// Build a "already recorded" set so we don't double-attach the same
|
||||
// file when several turns share the sandbox. Errors here degrade to an
|
||||
// empty set: attaching duplicates is a soft failure, aborting is not.
|
||||
known := c.loadKnownSet(ctx, sessionID)
|
||||
if len(known) > 0 {
|
||||
logger.Infof(ctx, "[ArtifactCollector] known set size=%d (session=%s)", len(known), sessionID)
|
||||
}
|
||||
|
||||
pending := 0
|
||||
for _, entry := range entries {
|
||||
if c.acceptEntry(entry, known) {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
if pending > 0 && notify != nil {
|
||||
notify(pending)
|
||||
}
|
||||
|
||||
artifacts = make(types.MessageArtifacts, 0, pending)
|
||||
for _, entry := range entries {
|
||||
art, ok := c.maybePersist(ctx, source, sessionID, messageID, tenantID, entry, known)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
artifacts = append(artifacts, art)
|
||||
// Adding to the known set inside the loop protects us against the
|
||||
// pathological case where ListSessionFiles returns the same path
|
||||
// twice (envd hasn't been observed to do so, but future-proofing
|
||||
// the loop is cheap).
|
||||
known[artifactKey(art.SourcePath, art.ModTime)] = struct{}{}
|
||||
}
|
||||
logger.Infof(ctx, "[ArtifactCollector] done session=%s listed=%d attached=%d",
|
||||
sessionID, len(entries), len(artifacts))
|
||||
return artifacts, nil
|
||||
}
|
||||
|
||||
// loadKnownSet returns the (source_path, mod_time) tuples already recorded
|
||||
// against the session. Empty on error so the caller can proceed.
|
||||
func (c *ArtifactCollector) loadKnownSet(ctx context.Context, sessionID string) map[string]struct{} {
|
||||
set := map[string]struct{}{}
|
||||
if c.store == nil {
|
||||
return set
|
||||
}
|
||||
prev, err := c.store.KnownArtifacts(ctx, sessionID)
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "[ArtifactCollector] load previous artifacts failed: session=%s err=%v",
|
||||
sessionID, err)
|
||||
return set
|
||||
}
|
||||
for _, p := range prev {
|
||||
set[artifactKey(p.SourcePath, p.ModTime)] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func (c *ArtifactCollector) acceptEntry(entry sandbox.RemoteDirEntry, known map[string]struct{}) bool {
|
||||
if entry.Type != sandbox.RemoteEntryFile {
|
||||
return false
|
||||
}
|
||||
if entry.Path == "" || entry.Name == "" {
|
||||
return false
|
||||
}
|
||||
if entry.Size > c.config.MaxFileBytes {
|
||||
return false
|
||||
}
|
||||
if _, seen := known[artifactKey(entry.Path, entry.ModTime)]; seen {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// maybePersist runs the per-file pipeline (filter → download → upload →
|
||||
// build metadata). Returns ok=false when the entry was skipped for any
|
||||
// reason (already known, too large, upload failed). All skip reasons are
|
||||
// logged so operators can diagnose empty artifact panels.
|
||||
func (c *ArtifactCollector) maybePersist(
|
||||
ctx context.Context,
|
||||
source SandboxArtifactSource,
|
||||
sessionID string,
|
||||
messageID string,
|
||||
tenantID uint64,
|
||||
entry sandbox.RemoteDirEntry,
|
||||
known map[string]struct{},
|
||||
) (types.MessageArtifact, bool) {
|
||||
if !c.acceptEntry(entry, known) {
|
||||
if entry.Type == sandbox.RemoteEntryFile && entry.Size > c.config.MaxFileBytes {
|
||||
logger.Warnf(ctx, "[ArtifactCollector] skip oversize artifact: session=%s path=%s size=%d limit=%d",
|
||||
sessionID, entry.Path, entry.Size, c.config.MaxFileBytes)
|
||||
}
|
||||
return types.MessageArtifact{}, false
|
||||
}
|
||||
|
||||
data, err := source.ReadSessionFile(ctx, sessionID, entry.Path)
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "[ArtifactCollector] read artifact failed: session=%s path=%s err=%v",
|
||||
sessionID, entry.Path, err)
|
||||
return types.MessageArtifact{}, false
|
||||
}
|
||||
// A second guard: envd may report a stale size while the file is being
|
||||
// re-written; enforce the cap against the actual byte count too.
|
||||
if int64(len(data)) > c.config.MaxFileBytes {
|
||||
logger.Warnf(ctx, "[ArtifactCollector] skip oversize artifact after read: session=%s path=%s size=%d limit=%d",
|
||||
sessionID, entry.Path, len(data), c.config.MaxFileBytes)
|
||||
return types.MessageArtifact{}, false
|
||||
}
|
||||
|
||||
// Give each blob a UUID-namespaced storage name so concurrent turns
|
||||
// cannot collide, and so the storage key itself is unguessable from
|
||||
// the outside (defence-in-depth on top of the /artifacts/:index
|
||||
// endpoint's ownership check).
|
||||
storageName := "artifact_" + uuid.NewString() + "_" + safeFileName(entry.Name)
|
||||
storagePath, err := c.fileService.SaveBytes(ctx, data, tenantID, storageName, false)
|
||||
if err != nil {
|
||||
logger.Warnf(ctx, "[ArtifactCollector] upload artifact failed: session=%s path=%s err=%v",
|
||||
sessionID, entry.Path, err)
|
||||
return types.MessageArtifact{}, false
|
||||
}
|
||||
|
||||
c.bindArtifactResource(ctx, storagePath, messageID)
|
||||
|
||||
return types.MessageArtifact{
|
||||
URL: storagePath,
|
||||
FileName: entry.Name,
|
||||
FileType: strings.ToLower(filepath.Ext(entry.Name)),
|
||||
FileSize: int64(len(data)),
|
||||
SourcePath: entry.Path,
|
||||
ModTime: entry.ModTime,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}, true
|
||||
}
|
||||
|
||||
// bindArtifactResource records that the freshly-persisted artifact resource
|
||||
// is owned by its assistant message. Best-effort: a binding failure never
|
||||
// discards the artifact, because the file is already stored and remains
|
||||
// downloadable through the /artifacts endpoint regardless of the binding.
|
||||
//
|
||||
// The binding is only attempted when (a) the catalog is wired in, (b) we have
|
||||
// a message ID to own the resource, and (c) SaveBytes actually returned a
|
||||
// resource:// reference — i.e. the file service is resource-catalog-backed.
|
||||
// Raw provider paths (no catalog decorator) are left unbound rather than
|
||||
// generating spurious "invalid resource reference" errors.
|
||||
func (c *ArtifactCollector) bindArtifactResource(ctx context.Context, ref, messageID string) {
|
||||
if c.catalog == nil || messageID == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := types.ParseResourcePath(ref); !ok {
|
||||
return
|
||||
}
|
||||
if err := c.catalog.Bind(ctx, ref, artifactBindingOwnerType, messageID, artifactBindingRelation); err != nil {
|
||||
logger.Warnf(ctx, "[ArtifactCollector] bind artifact resource failed: message=%s ref=%s err=%v",
|
||||
messageID, ref, err)
|
||||
}
|
||||
}
|
||||
|
||||
// artifactKey is the string form of the (source_path, mtime) tuple used to
|
||||
// de-duplicate artifacts across messages. mtime is normalised to UTC + RFC3339
|
||||
// nano so equality is stable across time-zone or precision differences
|
||||
// between sandbox envd builds.
|
||||
func artifactKey(path string, mod time.Time) string {
|
||||
if mod.IsZero() {
|
||||
return path + "\x00"
|
||||
}
|
||||
return path + "\x00" + mod.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// safeFileName strips slashes and backslashes from the original name before
|
||||
// concatenating it into the storage key. The FileService may or may not
|
||||
// sanitise on its own; belt-and-suspenders here avoids provider-specific
|
||||
// surprises (e.g. object stores that treat "/" as delimiter).
|
||||
func safeFileName(name string) string {
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
if name == "" {
|
||||
return "unnamed"
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// messageRepoArtifactStore adapts interfaces.MessageRepository to the
|
||||
// SessionArtifactStore contract expected by ArtifactCollector. It is a thin
|
||||
// projection: KnownArtifacts is documented as best-effort, so we forward
|
||||
// repo errors verbatim and let the collector decide how to degrade.
|
||||
type messageRepoArtifactStore struct {
|
||||
repo interfaces.MessageRepository
|
||||
}
|
||||
|
||||
// NewMessageRepoArtifactStore wraps a MessageRepository so ArtifactCollector
|
||||
// can build its de-duplication set from every prior message of the session.
|
||||
func NewMessageRepoArtifactStore(repo interfaces.MessageRepository) SessionArtifactStore {
|
||||
return &messageRepoArtifactStore{repo: repo}
|
||||
}
|
||||
|
||||
// KnownArtifacts satisfies SessionArtifactStore.
|
||||
func (s *messageRepoArtifactStore) KnownArtifacts(
|
||||
ctx context.Context, sessionID string,
|
||||
) ([]types.MessageArtifact, error) {
|
||||
if s == nil || s.repo == nil || sessionID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
arts, err := s.repo.GetSessionArtifacts(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return arts, nil
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/sandbox"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Test doubles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// fakeSandboxSource is an in-memory stand-in for SessionBoundManager that
|
||||
// records the calls the collector makes and returns pre-programmed
|
||||
// responses. Concrete tests populate `entries` (one map per session) and
|
||||
// `contents` (one map per absolute path).
|
||||
type fakeSandboxSource struct {
|
||||
entries map[string][]sandbox.RemoteDirEntry
|
||||
contents map[string][]byte
|
||||
|
||||
listErr error
|
||||
readErr error
|
||||
|
||||
listCalls int
|
||||
readCalls []string
|
||||
}
|
||||
|
||||
func (f *fakeSandboxSource) ListSessionFiles(_ context.Context, sessionID, _ string) ([]sandbox.RemoteDirEntry, error) {
|
||||
f.listCalls++
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
return f.entries[sessionID], nil
|
||||
}
|
||||
|
||||
func (f *fakeSandboxSource) ReadSessionFile(_ context.Context, _ string, path string) ([]byte, error) {
|
||||
f.readCalls = append(f.readCalls, path)
|
||||
if f.readErr != nil {
|
||||
return nil, f.readErr
|
||||
}
|
||||
if data, ok := f.contents[path]; ok {
|
||||
return data, nil
|
||||
}
|
||||
return nil, stderrors.New("fake source: not found: " + path)
|
||||
}
|
||||
|
||||
// fakeStore lets a test declare which (path, mtime) tuples the collector
|
||||
// should treat as already recorded.
|
||||
type fakeStore struct {
|
||||
prev []types.MessageArtifact
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakeStore) KnownArtifacts(_ context.Context, _ string) ([]types.MessageArtifact, error) {
|
||||
return s.prev, s.err
|
||||
}
|
||||
|
||||
// fakeFileService captures uploads and returns a deterministic provider URL.
|
||||
// Only SaveBytes is exercised by the collector; the other methods are
|
||||
// implemented to satisfy interfaces.FileService but panic on use so a
|
||||
// regression that starts calling them doesn't fail silently.
|
||||
type fakeFileService struct {
|
||||
saved map[string][]byte
|
||||
saveErr error
|
||||
seq int
|
||||
tenantIDs []uint64
|
||||
}
|
||||
|
||||
func (f *fakeFileService) CheckConnectivity(_ context.Context) error { return nil }
|
||||
|
||||
func (f *fakeFileService) SaveFile(_ context.Context, _ *multipart.FileHeader, _ uint64, _ string) (string, error) {
|
||||
panic("SaveFile should not be called by ArtifactCollector")
|
||||
}
|
||||
|
||||
func (f *fakeFileService) SaveBytes(_ context.Context, data []byte, tenantID uint64, fileName string, _ bool) (string, error) {
|
||||
if f.saveErr != nil {
|
||||
return "", f.saveErr
|
||||
}
|
||||
if f.saved == nil {
|
||||
f.saved = map[string][]byte{}
|
||||
}
|
||||
f.seq++
|
||||
key := "fake://tenant-" + fileName
|
||||
f.saved[key] = append([]byte(nil), data...)
|
||||
f.tenantIDs = append(f.tenantIDs, tenantID)
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (f *fakeFileService) GetFile(_ context.Context, _ string) (io.ReadCloser, error) {
|
||||
panic("GetFile should not be called by ArtifactCollector")
|
||||
}
|
||||
|
||||
func (f *fakeFileService) GetFileURL(_ context.Context, _ string) (string, error) {
|
||||
panic("GetFileURL should not be called by ArtifactCollector")
|
||||
}
|
||||
|
||||
func (f *fakeFileService) DeleteFile(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func (f *fakeFileService) CopyFile(_ context.Context, _ string, _ uint64, _ string) (string, error) {
|
||||
panic("CopyFile should not be called by ArtifactCollector")
|
||||
}
|
||||
|
||||
// resourceRefFileService is a fileService whose SaveBytes returns a valid
|
||||
// resource:// reference, so the collector's binding path is exercised.
|
||||
type resourceRefFileService struct {
|
||||
fakeFileService
|
||||
handle string
|
||||
}
|
||||
|
||||
func (f *resourceRefFileService) SaveBytes(_ context.Context, data []byte, tenantID uint64, fileName string, _ bool) (string, error) {
|
||||
if f.saved == nil {
|
||||
f.saved = map[string][]byte{}
|
||||
}
|
||||
f.seq++
|
||||
ref := types.BuildResourcePath(f.handle)
|
||||
f.saved[ref] = append([]byte(nil), data...)
|
||||
f.tenantIDs = append(f.tenantIDs, tenantID)
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// bindCall records one Bind invocation for assertions.
|
||||
type bindCall struct {
|
||||
ref string
|
||||
ownerType string
|
||||
ownerID string
|
||||
relation string
|
||||
}
|
||||
|
||||
// fakeCatalog captures Bind calls; every other ResourceCatalog method is a
|
||||
// no-op stub because the collector only ever calls Bind.
|
||||
type fakeCatalog struct {
|
||||
binds []bindCall
|
||||
bindErr error
|
||||
// Release scripting, used by the deletion-guard tests. releaseRemaining
|
||||
// maps a reference to the binding count left after the release; a missing
|
||||
// entry answers -1 ("not a catalog handle").
|
||||
releaseRemaining map[string]int64
|
||||
releaseErr error
|
||||
releases []string
|
||||
}
|
||||
|
||||
func (c *fakeCatalog) Register(context.Context, uint64, string, interfaces.ResourceRegistration) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (c *fakeCatalog) Resolve(context.Context, string) (*types.StoredResource, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (c *fakeCatalog) ResolvePath(_ context.Context, v string) (string, *types.StoredResource, error) {
|
||||
return v, nil, nil
|
||||
}
|
||||
func (c *fakeCatalog) Bind(_ context.Context, ref, ownerType, ownerID, relation string) error {
|
||||
c.binds = append(c.binds, bindCall{ref, ownerType, ownerID, relation})
|
||||
return c.bindErr
|
||||
}
|
||||
func (c *fakeCatalog) MarkDeleted(context.Context, string) error { return nil }
|
||||
|
||||
func (c *fakeCatalog) Release(_ context.Context, ref, ownerType, ownerID string) (int64, error) {
|
||||
c.releases = append(c.releases, ref+"|"+ownerType+"|"+ownerID)
|
||||
if c.releaseErr != nil {
|
||||
return -1, c.releaseErr
|
||||
}
|
||||
if remaining, ok := c.releaseRemaining[ref]; ok {
|
||||
return remaining, nil
|
||||
}
|
||||
return -1, nil
|
||||
}
|
||||
func (c *fakeCatalog) CreateAccessGrant(context.Context, string, time.Duration) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (c *fakeCatalog) ResolveAccessGrant(context.Context, string) (*types.StoredResource, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func newTestCollector(src *fakeSandboxSource, store *fakeStore, fs *fakeFileService, max int64) *ArtifactCollector {
|
||||
// catalog is nil here: these tests use a fakeFileService that returns raw
|
||||
// "fake://" paths, so no resource binding is attempted. Binding behaviour
|
||||
// is covered separately in TestArtifactCollector_BindsResourceToMessage.
|
||||
return NewArtifactCollector(src, fs, store, nil, ArtifactCollectorConfig{MaxFileBytes: max})
|
||||
}
|
||||
|
||||
func TestArtifactCollector_CollectsNewFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSandboxSource{
|
||||
entries: map[string][]sandbox.RemoteDirEntry{
|
||||
"sess-1": {
|
||||
{Name: "report.pptx", Path: "/workspace/output/report.pptx", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
||||
{Name: "summary.txt", Path: "/workspace/output/summary.txt", Type: sandbox.RemoteEntryFile, Size: 3, ModTime: mustParseTime("2026-07-10T10:20:34Z")},
|
||||
},
|
||||
},
|
||||
contents: map[string][]byte{
|
||||
"/workspace/output/report.pptx": []byte("PPTX"),
|
||||
"/workspace/output/summary.txt": []byte("hey"),
|
||||
},
|
||||
}
|
||||
store := &fakeStore{}
|
||||
fs := &fakeFileService{}
|
||||
c := newTestCollector(src, store, fs, 1<<20)
|
||||
|
||||
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("Collect() len = %d, want 2 (%+v)", len(got), got)
|
||||
}
|
||||
// Files must be uploaded with the tenant ID passed by the caller.
|
||||
for _, tID := range fs.tenantIDs {
|
||||
if tID != 42 {
|
||||
t.Fatalf("SaveBytes tenant = %d, want 42", tID)
|
||||
}
|
||||
}
|
||||
// Both files must have populated URL + FileType + FileSize.
|
||||
for _, art := range got {
|
||||
if art.URL == "" {
|
||||
t.Fatalf("artifact URL empty: %+v", art)
|
||||
}
|
||||
if art.FileType == "" {
|
||||
t.Fatalf("artifact FileType empty: %+v", art)
|
||||
}
|
||||
if art.FileSize == 0 {
|
||||
t.Fatalf("artifact FileSize zero: %+v", art)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollector_NotifyFiresBeforeUpload(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSandboxSource{
|
||||
entries: map[string][]sandbox.RemoteDirEntry{
|
||||
"sess-1": {
|
||||
{
|
||||
Name: "a.html", Path: "/workspace/output/a.html",
|
||||
Type: sandbox.RemoteEntryFile, Size: 2,
|
||||
ModTime: mustParseTime("2026-07-10T10:20:33Z"),
|
||||
},
|
||||
{
|
||||
Name: "b.csv", Path: "/workspace/output/b.csv",
|
||||
Type: sandbox.RemoteEntryFile, Size: 2,
|
||||
ModTime: mustParseTime("2026-07-10T10:20:34Z"),
|
||||
},
|
||||
},
|
||||
},
|
||||
contents: map[string][]byte{
|
||||
"/workspace/output/a.html": []byte("ok"),
|
||||
"/workspace/output/b.csv": []byte("x,"),
|
||||
},
|
||||
}
|
||||
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
|
||||
|
||||
var notified int
|
||||
got, err := c.CollectWithNotify(ctx, "sess-1", "msg-1", 42, "/workspace/output", func(n int) {
|
||||
if src.readCalls != nil {
|
||||
t.Fatalf("notify ran after ReadSessionFile: %v", src.readCalls)
|
||||
}
|
||||
notified = n
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CollectWithNotify() error = %v", err)
|
||||
}
|
||||
if notified != 2 {
|
||||
t.Fatalf("notify count = %d, want 2", notified)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("CollectWithNotify() len = %d, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollector_SkipsAlreadyKnown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mod, _ := time.Parse(time.RFC3339, "2026-07-10T10:20:33Z")
|
||||
src := &fakeSandboxSource{
|
||||
entries: map[string][]sandbox.RemoteDirEntry{
|
||||
"sess-1": {
|
||||
{Name: "report.pptx", Path: "/workspace/output/report.pptx", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
||||
},
|
||||
},
|
||||
contents: map[string][]byte{
|
||||
"/workspace/output/report.pptx": []byte("PPTX"),
|
||||
},
|
||||
}
|
||||
store := &fakeStore{prev: []types.MessageArtifact{
|
||||
{SourcePath: "/workspace/output/report.pptx", ModTime: mod},
|
||||
}}
|
||||
fs := &fakeFileService{}
|
||||
c := newTestCollector(src, store, fs, 1<<20)
|
||||
|
||||
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("Collect() len = %d, want 0 (dedupe should have kicked in)", len(got))
|
||||
}
|
||||
if len(fs.saved) != 0 {
|
||||
t.Fatalf("SaveBytes should not have been called; saved=%v", fs.saved)
|
||||
}
|
||||
if len(src.readCalls) != 0 {
|
||||
t.Fatalf("ReadSessionFile should not have been called; readCalls=%v", src.readCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollector_ReattachesOnMtimeChange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
oldMod, _ := time.Parse(time.RFC3339, "2026-07-10T10:20:33Z")
|
||||
src := &fakeSandboxSource{
|
||||
entries: map[string][]sandbox.RemoteDirEntry{
|
||||
"sess-1": {
|
||||
// Same path as the known set, but a *newer* mtime — must be re-attached.
|
||||
{Name: "report.pptx", Path: "/workspace/output/report.pptx", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:21:00Z")},
|
||||
},
|
||||
},
|
||||
contents: map[string][]byte{
|
||||
"/workspace/output/report.pptx": []byte("PPTX"),
|
||||
},
|
||||
}
|
||||
store := &fakeStore{prev: []types.MessageArtifact{
|
||||
{SourcePath: "/workspace/output/report.pptx", ModTime: oldMod},
|
||||
}}
|
||||
fs := &fakeFileService{}
|
||||
c := newTestCollector(src, store, fs, 1<<20)
|
||||
|
||||
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Collect() len = %d, want 1 (mtime-change should re-attach)", len(got))
|
||||
}
|
||||
if len(fs.saved) != 1 {
|
||||
t.Fatalf("SaveBytes calls = %d, want 1", len(fs.saved))
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollector_SkipsOversize(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSandboxSource{
|
||||
entries: map[string][]sandbox.RemoteDirEntry{
|
||||
"sess-1": {
|
||||
{Name: "huge.bin", Path: "/workspace/output/huge.bin", Type: sandbox.RemoteEntryFile, Size: 1024, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
||||
{Name: "ok.txt", Path: "/workspace/output/ok.txt", Type: sandbox.RemoteEntryFile, Size: 3, ModTime: mustParseTime("2026-07-10T10:20:34Z")},
|
||||
},
|
||||
},
|
||||
contents: map[string][]byte{
|
||||
"/workspace/output/huge.bin": bytesN(2048),
|
||||
"/workspace/output/ok.txt": []byte("hey"),
|
||||
},
|
||||
}
|
||||
fs := &fakeFileService{}
|
||||
c := newTestCollector(src, &fakeStore{}, fs, 100)
|
||||
|
||||
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Collect() len = %d, want 1 (oversize file must be skipped)", len(got))
|
||||
}
|
||||
if got[0].FileName != "ok.txt" {
|
||||
t.Fatalf("Collect() kept the wrong file: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollector_SkipsOversizeAfterRead(t *testing.T) {
|
||||
// Envd can report a stale size while the file grows; the collector must
|
||||
// enforce the cap against the actual bytes too.
|
||||
ctx := context.Background()
|
||||
src := &fakeSandboxSource{
|
||||
entries: map[string][]sandbox.RemoteDirEntry{
|
||||
"sess-1": {
|
||||
{Name: "lying.bin", Path: "/workspace/output/lying.bin", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
||||
},
|
||||
},
|
||||
contents: map[string][]byte{
|
||||
"/workspace/output/lying.bin": bytesN(2048),
|
||||
},
|
||||
}
|
||||
fs := &fakeFileService{}
|
||||
c := newTestCollector(src, &fakeStore{}, fs, 100)
|
||||
|
||||
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("Collect() len = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollector_EmptyWhenNoEntries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSandboxSource{}
|
||||
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
|
||||
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("Collect() len = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollector_EmptyWhenNoSessionID(t *testing.T) {
|
||||
// The collector treats empty session ID as "nothing to do" — the design
|
||||
// spec forbids sandbox lookups for chat-only sessions.
|
||||
ctx := context.Background()
|
||||
src := &fakeSandboxSource{}
|
||||
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
|
||||
got, err := c.Collect(ctx, "", "msg-1", 42, "/workspace/output")
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("Collect() len = %d, want 0", len(got))
|
||||
}
|
||||
if src.listCalls != 0 {
|
||||
t.Fatalf("expected 0 list calls for empty session id, got %d", src.listCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollector_ListErrorDegrades(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSandboxSource{listErr: stderrors.New("envd timeout")}
|
||||
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
|
||||
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v (should degrade gracefully)", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("Collect() len = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollector_UploadFailureIsPerFile(t *testing.T) {
|
||||
// A single failed upload must NOT abort the whole batch — the other
|
||||
// files should still be persisted, matching the best-effort contract.
|
||||
ctx := context.Background()
|
||||
src := &fakeSandboxSource{
|
||||
entries: map[string][]sandbox.RemoteDirEntry{
|
||||
"sess-1": {
|
||||
{Name: "a.txt", Path: "/workspace/output/a.txt", Type: sandbox.RemoteEntryFile, Size: 1, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
||||
{Name: "b.txt", Path: "/workspace/output/b.txt", Type: sandbox.RemoteEntryFile, Size: 1, ModTime: mustParseTime("2026-07-10T10:20:34Z")},
|
||||
},
|
||||
},
|
||||
contents: map[string][]byte{
|
||||
"/workspace/output/a.txt": []byte("a"),
|
||||
"/workspace/output/b.txt": []byte("b"),
|
||||
},
|
||||
}
|
||||
// FileService fails on every call — both files should be skipped without
|
||||
// propagating an error.
|
||||
fs := &fakeFileService{saveErr: stderrors.New("s3 dead")}
|
||||
c := newTestCollector(src, &fakeStore{}, fs, 1<<20)
|
||||
|
||||
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v (want best-effort)", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("Collect() len = %d, want 0 (all uploads failed)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollector_FiltersDirectories(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSandboxSource{
|
||||
entries: map[string][]sandbox.RemoteDirEntry{
|
||||
"sess-1": {
|
||||
// The production ListSessionFiles never yields dirs, but the
|
||||
// collector must defensively skip anything with Type=="dir"
|
||||
// so alternate SandboxArtifactSource impls stay safe.
|
||||
{Name: "sub", Path: "/workspace/output/sub", Type: sandbox.RemoteEntryDir, Size: 0, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
||||
{Name: "a.txt", Path: "/workspace/output/a.txt", Type: sandbox.RemoteEntryFile, Size: 1, ModTime: mustParseTime("2026-07-10T10:20:34Z")},
|
||||
},
|
||||
},
|
||||
contents: map[string][]byte{
|
||||
"/workspace/output/a.txt": []byte("a"),
|
||||
},
|
||||
}
|
||||
c := newTestCollector(src, &fakeStore{}, &fakeFileService{}, 1<<20)
|
||||
got, err := c.Collect(ctx, "sess-1", "msg-1", 42, "/workspace/output")
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Collect() len = %d, want 1 (dir must be skipped)", len(got))
|
||||
}
|
||||
if !strings.HasSuffix(got[0].SourcePath, "a.txt") {
|
||||
t.Fatalf("wrong file kept: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollector_BindsResourceToMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSandboxSource{
|
||||
entries: map[string][]sandbox.RemoteDirEntry{
|
||||
"sess-1": {
|
||||
{Name: "report.pptx", Path: "/workspace/output/report.pptx", Type: sandbox.RemoteEntryFile, Size: 4, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
||||
},
|
||||
},
|
||||
contents: map[string][]byte{
|
||||
"/workspace/output/report.pptx": []byte("PPTX"),
|
||||
},
|
||||
}
|
||||
// 22-char handle so BuildResourcePath yields a valid resource:// ref.
|
||||
fs := &resourceRefFileService{handle: "abcdefghijklmnopqrstuv"}
|
||||
cat := &fakeCatalog{}
|
||||
c := NewArtifactCollector(src, fs, &fakeStore{}, cat, ArtifactCollectorConfig{MaxFileBytes: 1 << 20})
|
||||
|
||||
got, err := c.Collect(ctx, "sess-1", "msg-42", 7, "/workspace/output")
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Collect() len = %d, want 1", len(got))
|
||||
}
|
||||
if len(cat.binds) != 1 {
|
||||
t.Fatalf("Bind calls = %d, want 1 (%+v)", len(cat.binds), cat.binds)
|
||||
}
|
||||
b := cat.binds[0]
|
||||
if b.ownerType != "message" || b.ownerID != "msg-42" || b.relation != "artifact" {
|
||||
t.Fatalf("unexpected binding: %+v", b)
|
||||
}
|
||||
if _, ok := types.ParseResourcePath(b.ref); !ok {
|
||||
t.Fatalf("binding ref is not a resource reference: %q", b.ref)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollector_BindFailureDoesNotDropArtifact(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := &fakeSandboxSource{
|
||||
entries: map[string][]sandbox.RemoteDirEntry{
|
||||
"sess-1": {
|
||||
{Name: "a.txt", Path: "/workspace/output/a.txt", Type: sandbox.RemoteEntryFile, Size: 1, ModTime: mustParseTime("2026-07-10T10:20:33Z")},
|
||||
},
|
||||
},
|
||||
contents: map[string][]byte{"/workspace/output/a.txt": []byte("a")},
|
||||
}
|
||||
fs := &resourceRefFileService{handle: "abcdefghijklmnopqrstuv"}
|
||||
cat := &fakeCatalog{bindErr: stderrors.New("db down")}
|
||||
c := NewArtifactCollector(src, fs, &fakeStore{}, cat, ArtifactCollectorConfig{MaxFileBytes: 1 << 20})
|
||||
|
||||
got, err := c.Collect(ctx, "sess-1", "msg-1", 7, "/workspace/output")
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Collect() len = %d, want 1 (bind failure must not drop the artifact)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// mustParseTime parses an RFC3339 timestamp for test data. It fails the
|
||||
// program on error because test fixtures should never contain malformed
|
||||
// timestamps.
|
||||
func mustParseTime(raw string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339Nano, raw)
|
||||
if err != nil {
|
||||
t, err = time.Parse(time.RFC3339, raw)
|
||||
}
|
||||
if err != nil {
|
||||
panic("mustParseTime: " + err.Error())
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// bytesN returns a byte slice of length n filled with 'x'. Kept local to
|
||||
// avoid pulling in a dependency; matches "generate N bytes" test helpers
|
||||
// elsewhere in the codebase.
|
||||
func bytesN(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = 'x'
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/Tencent/WeKnora/internal/accesspolicy"
|
||||
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func assetAdministrator(ctx context.Context) bool {
|
||||
return types.IsSystemAdminFromContext(ctx) || types.TenantRoleFromContext(ctx).HasPermission(types.TenantRoleAdmin)
|
||||
}
|
||||
|
||||
func assetOwnedByCaller(ctx context.Context, tenant uint64, owner string) bool {
|
||||
current, ok := types.TenantIDFromContext(ctx)
|
||||
if !ok || current == 0 || current != tenant {
|
||||
return false
|
||||
}
|
||||
if assetAdministrator(ctx) {
|
||||
return true
|
||||
}
|
||||
user, ok := types.UserIDFromContext(ctx)
|
||||
return ok && user != "" && !types.IsSyntheticUserID(user) && owner != "" && owner == user
|
||||
}
|
||||
|
||||
// CheckKnowledgeBaseCopyPermission is used before enqueueing and again by the
|
||||
// worker. Jobs without a human initiator retain their internal tenant scope.
|
||||
func CheckKnowledgeBaseCopyPermission(ctx context.Context, kb *types.KnowledgeBase) error {
|
||||
tenant, ok := types.TenantIDFromContext(ctx)
|
||||
if kb == nil || !ok || tenant == 0 || kb.TenantID != tenant {
|
||||
return apperrors.NewForbiddenError("没有此知识库的复制权限")
|
||||
}
|
||||
if assetAdministrator(ctx) {
|
||||
return nil
|
||||
}
|
||||
user, hasUser := types.UserIDFromContext(ctx)
|
||||
if _, apiKey := types.TenantAPIKeyScopeFromContext(ctx); apiKey {
|
||||
return types.AuthorizeTenantAPIKeyKnowledgeBases(ctx, kb.ID)
|
||||
}
|
||||
if !hasUser && accesspolicy.FromContext(ctx) == nil {
|
||||
return nil
|
||||
}
|
||||
if user == "" || types.IsSyntheticUserID(user) || user != kb.CreatorID {
|
||||
return apperrors.NewForbiddenError("共享知识库仅支持查看和使用,不能复制")
|
||||
}
|
||||
if snapshot := accesspolicy.FromContext(ctx); snapshot != nil && snapshot.NoExport["knowledge:"+kb.ID] {
|
||||
return apperrors.NewForbiddenError("此知识库不允许复制或导出")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nativeAgentReadable(ctx context.Context, agent *types.CustomAgent) bool {
|
||||
if agent == nil {
|
||||
return false
|
||||
}
|
||||
agent.EnsureDefaults()
|
||||
tenant, ok := types.TenantIDFromContext(ctx)
|
||||
if !agent.Enabled && !assetOwnedByCaller(ctx, agent.TenantID, agent.CreatedBy) {
|
||||
return false
|
||||
}
|
||||
return ok && tenant != 0 && tenant == agent.TenantID &&
|
||||
accesspolicy.Allowed(ctx, "agents.read") && accesspolicy.Readable(ctx, "agents", agent.ID, assetOwnedByCaller(ctx, tenant, agent.CreatedBy), agent.Visibility == "global" && agent.Enabled)
|
||||
}
|
||||
|
||||
func nativeAgentExecutableDependency(ctx context.Context, agent *types.CustomAgent) bool {
|
||||
if agent == nil {
|
||||
return false
|
||||
}
|
||||
agent.EnsureDefaults()
|
||||
return agent.Enabled && accesspolicy.CanExecuteDependency(ctx, agent.TenantID, "agents", agent.ID, agent.CreatedBy)
|
||||
}
|
||||
|
||||
func instructionSkillReadable(ctx context.Context, skill *types.TenantSkillCatalogEntity) bool {
|
||||
if skill == nil || !skill.InstructionOnly {
|
||||
return false
|
||||
}
|
||||
skill.EnsureInstructionDefaults()
|
||||
tenant, ok := types.TenantIDFromContext(ctx)
|
||||
if !skill.Enabled && !assetOwnedByCaller(ctx, skill.TenantID, skill.CreatedBy) {
|
||||
return false
|
||||
}
|
||||
return ok && tenant != 0 && tenant == skill.TenantID &&
|
||||
accesspolicy.Allowed(ctx, "agents.read") && accesspolicy.Readable(ctx, "skills", skill.ID, assetOwnedByCaller(ctx, tenant, skill.CreatedBy), skill.Visibility == "global" && skill.Enabled)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/accesspolicy"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKnowledgeBaseCopyRejectsSharedSourcesAndTargets(t *testing.T) {
|
||||
repo := newFakeKBRepo()
|
||||
repo.rows["shared"] = &types.KnowledgeBase{ID: "shared", TenantID: 1, CreatorID: "alice", Name: "Shared"}
|
||||
repo.rows["owned"] = &types.KnowledgeBase{ID: "owned", TenantID: 1, CreatorID: "bob", Name: "Owned"}
|
||||
svc := newPR3KBService(repo, &fakeRegistry{}, &fakeOwnership{})
|
||||
bob := accesspolicy.WithSnapshot(instructionContext(1, "bob", types.TenantRoleContributor), &accesspolicy.Snapshot{Publications: map[string]bool{"knowledge:shared": true}})
|
||||
_, _, err := svc.CopyKnowledgeBase(bob, "shared", "")
|
||||
require.ErrorContains(t, err, "不能复制")
|
||||
_, err = svc.DuplicateKnowledgeBase(bob, "shared")
|
||||
require.ErrorContains(t, err, "不能复制")
|
||||
_, _, err = svc.CopyKnowledgeBase(bob, "owned", "shared")
|
||||
require.ErrorContains(t, err, "不能复制")
|
||||
require.Len(t, repo.rows, 2, "denied copies must not create destination rows")
|
||||
_, _, err = svc.CopyKnowledgeBase(bob, "owned", "")
|
||||
require.NoError(t, err)
|
||||
admin := instructionContext(1, "admin", types.TenantRoleAdmin)
|
||||
_, _, err = svc.CopyKnowledgeBase(admin, "shared", "")
|
||||
require.NoError(t, err)
|
||||
require.Error(t, CheckKnowledgeBaseCopyPermission(instructionContext(2, "admin", types.TenantRoleAdmin), repo.rows["shared"]))
|
||||
}
|
||||
|
||||
func TestAgentCopyPreservesOwnerAndAdminPermissions(t *testing.T) {
|
||||
repo := &lifecycleAgentRepo{rows: map[string]*types.CustomAgent{"a": {ID: "a", TenantID: 1, CreatedBy: "alice", Name: "A", Visibility: "global", Enabled: true}}}
|
||||
svc := &customAgentService{repo: repo}
|
||||
for _, identity := range []struct {
|
||||
user string
|
||||
role types.TenantRole
|
||||
}{{"alice", types.TenantRoleContributor}, {"admin", types.TenantRoleAdmin}} {
|
||||
copy, err := svc.CopyAgent(instructionContext(1, identity.user, identity.role), "a")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, identity.user, copy.CreatedBy)
|
||||
require.Equal(t, "private", copy.Visibility)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/application/repository"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGlobalInstructionSkillCanBeReadMountedButNotOverwrittenByOthers(t *testing.T) {
|
||||
s, alice := instructionHarness(t)
|
||||
bob := instructionContext(1, "bob", types.TenantRoleContributor)
|
||||
skill, err := s.Import(alice, "global.md", "", "", []byte("shared rules"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.db.Model(&types.TenantSkillCatalogEntity{}).Where("id = ?", skill.ID).Update("visibility", "global").Error)
|
||||
view, err := s.Get(bob, skill.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "shared rules", view.Instructions)
|
||||
require.Equal(t, "global", view.Visibility)
|
||||
require.True(t, view.Enabled)
|
||||
list, err := s.List(bob)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1)
|
||||
agent := types.CustomAgent{ID: "bob-agent", TenantID: 1, CreatedBy: "bob", Name: "Bob"}
|
||||
require.NoError(t, s.db.Create(&agent).Error)
|
||||
_, err = s.Mount(bob, InstructionSkillMountRequest{AgentIDs: []string{agent.ID}, SkillIDs: []string{skill.ID}})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.db.First(&agent, "id = ?", agent.ID).Error)
|
||||
prompt, err := s.ResolveAgentInstructions(bob, &agent)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, prompt, "shared rules")
|
||||
require.Error(t, s.Delete(bob, skill.ID, true))
|
||||
copy, err := s.Import(bob, "global.md", "", "", []byte("bob rules"))
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, skill.ID, copy.ID)
|
||||
original, err := s.Get(alice, skill.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "shared rules", original.Instructions)
|
||||
}
|
||||
|
||||
func TestInstructionLifecycleIsCheckedAtExecutionTime(t *testing.T) {
|
||||
s, alice := instructionHarness(t)
|
||||
bob := instructionContext(1, "bob", types.TenantRoleContributor)
|
||||
skill, err := s.Import(alice, "rules.md", "", "", []byte("sensitive rules"))
|
||||
require.NoError(t, err)
|
||||
agent := types.CustomAgent{ID: "shared-agent", TenantID: 1, CreatedBy: "alice", Name: "Shared", Visibility: "global", Enabled: true, Config: types.CustomAgentConfig{InstructionSkillIDs: []string{skill.ID}}}
|
||||
prompt, err := s.ResolveAgentInstructions(bob, &agent)
|
||||
require.NoError(t, err, "sharing an Agent includes execution of its owner's bound rules")
|
||||
require.Contains(t, prompt, "sensitive rules")
|
||||
_, err = s.Get(bob, skill.ID)
|
||||
require.Error(t, err, "Agent execution must not independently expose the private skill")
|
||||
require.NoError(t, s.db.Model(&types.TenantSkillCatalogEntity{}).Where("id = ?", skill.ID).Update("visibility", "global").Error)
|
||||
prompt, err = s.ResolveAgentInstructions(bob, &agent)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, prompt, "sensitive rules")
|
||||
require.NoError(t, s.db.Model(&types.TenantSkillCatalogEntity{}).Where("id = ?", skill.ID).Update("enabled", false).Error)
|
||||
for _, ctx := range []context.Context{alice, bob, instructionContext(1, "admin", types.TenantRoleAdmin)} {
|
||||
prompt, err = s.ResolveAgentInstructions(ctx, &agent)
|
||||
require.Error(t, err)
|
||||
require.Empty(t, prompt)
|
||||
}
|
||||
_, err = s.Get(bob, skill.ID)
|
||||
require.Error(t, err)
|
||||
list, err := s.List(bob)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, list)
|
||||
updated, err := s.Import(alice, "rules.md", "", "", []byte("revised rules"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, updated.Enabled, "reimport must not reactivate a disabled skill")
|
||||
require.Equal(t, "global", updated.Visibility)
|
||||
agent.Config.InstructionSkillIDs = nil
|
||||
agent.Enabled = false
|
||||
_, err = s.ResolveAgentInstructions(alice, &agent)
|
||||
require.ErrorContains(t, err, "停用")
|
||||
agent.Enabled = true
|
||||
agent.Config.InstructionSkillIDs = []string{skill.ID}
|
||||
_, err = s.ResolveAgentInstructions(instructionContext(2, "admin", types.TenantRoleAdmin), &agent)
|
||||
require.ErrorContains(t, err, "跨工作空间")
|
||||
}
|
||||
|
||||
func TestNativeOrganizationExecutionWithoutInstructionSkillsKeepsCompatibility(t *testing.T) {
|
||||
shared := &types.CustomAgent{ID: "shared", TenantID: 1, CreatedBy: "alice", Visibility: "private", Enabled: true}
|
||||
// The organization-sharing service verifies a share before handing this
|
||||
// Agent to QA. There are no instruction bodies to authorize or expose.
|
||||
prompt, err := ResolveAgentInstructionPrompt(instructionContext(2, "bob", types.TenantRoleContributor), nil, shared)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, prompt)
|
||||
shared.Enabled = false
|
||||
_, err = ResolveAgentInstructionPrompt(instructionContext(2, "bob", types.TenantRoleAdmin), nil, shared)
|
||||
require.ErrorContains(t, err, "停用")
|
||||
}
|
||||
|
||||
func TestDisabledInstructionSkillCannotBeMounted(t *testing.T) {
|
||||
s, alice := instructionHarness(t)
|
||||
skill, err := s.Import(alice, "disabled.md", "", "", []byte("disabled rules"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.db.Model(&types.TenantSkillCatalogEntity{}).Where("id = ?", skill.ID).Update("enabled", false).Error)
|
||||
require.NoError(t, s.db.Create(&types.CustomAgent{ID: "a", TenantID: 1, CreatedBy: "alice", Name: "A"}).Error)
|
||||
_, err = s.Mount(alice, InstructionSkillMountRequest{AgentIDs: []string{"a"}, SkillIDs: []string{skill.ID}})
|
||||
require.ErrorContains(t, err, "停用")
|
||||
require.NoError(t, s.db.Model(&types.CustomAgent{}).Where("id = ?", "a").Updates(map[string]any{"enabled": false, "config": types.CustomAgentConfig{InstructionSkillIDs: []string{skill.ID}}}).Error)
|
||||
_, err = s.Mount(alice, InstructionSkillMountRequest{AgentIDs: []string{"a"}, SkillIDs: []string{skill.ID}, Mode: "remove"})
|
||||
require.NoError(t, err, "owners must be able to repair disabled Agent bindings")
|
||||
var repaired types.CustomAgent
|
||||
require.NoError(t, s.db.First(&repaired, "id = ?", "a").Error)
|
||||
require.False(t, repaired.Enabled)
|
||||
require.Empty(t, repaired.Config.InstructionSkillIDs)
|
||||
}
|
||||
|
||||
func TestDisabledBuiltinOverrideIsNotResurrected(t *testing.T) {
|
||||
ids := types.GetBuiltinAgentIDs()
|
||||
if len(ids) == 0 {
|
||||
t.Skip("no builtin registry configured")
|
||||
}
|
||||
id := ids[0]
|
||||
repo := &lifecycleAgentRepo{rows: map[string]*types.CustomAgent{id: {ID: id, TenantID: 1, IsBuiltin: true, Visibility: "global", Enabled: false}}}
|
||||
s := &customAgentService{repo: repo}
|
||||
bob := instructionContext(1, "bob", types.TenantRoleContributor)
|
||||
_, err := s.GetAgentByID(bob, id)
|
||||
require.ErrorIs(t, err, ErrAgentNotFound)
|
||||
list, err := s.ListAgents(bob)
|
||||
require.NoError(t, err)
|
||||
for _, a := range list {
|
||||
require.NotEqual(t, id, a.ID)
|
||||
}
|
||||
admin := instructionContext(1, "admin", types.TenantRoleAdmin)
|
||||
a, err := s.GetAgentByID(admin, id)
|
||||
require.NoError(t, err)
|
||||
_, err = ResolveAgentInstructionPrompt(admin, nil, a)
|
||||
require.ErrorContains(t, err, "停用")
|
||||
}
|
||||
|
||||
func TestLifecycleCreateDefaultsPreserveExplicitDisabled(t *testing.T) {
|
||||
s, _ := instructionHarness(t)
|
||||
agent := types.CustomAgent{ID: "disabled", TenantID: 1, Name: "D", Visibility: "private", Enabled: false}
|
||||
require.NoError(t, s.db.Create(&agent).Error)
|
||||
var stored types.CustomAgent
|
||||
require.NoError(t, s.db.First(&stored, "id = ?", agent.ID).Error)
|
||||
require.False(t, stored.Enabled)
|
||||
legacy := types.CustomAgent{ID: "legacy", TenantID: 1, Name: "L"}
|
||||
require.NoError(t, s.db.Create(&legacy).Error)
|
||||
require.True(t, legacy.Enabled)
|
||||
require.Equal(t, "global", legacy.Visibility)
|
||||
skill := types.TenantSkillCatalogEntity{ID: "disabled-skill", TenantID: 1, Name: "D", InstructionOnly: true, Visibility: "private", Enabled: false}
|
||||
require.NoError(t, s.db.Create(&skill).Error)
|
||||
var storedSkill types.TenantSkillCatalogEntity
|
||||
require.NoError(t, s.db.First(&storedSkill, "id = ?", skill.ID).Error)
|
||||
require.False(t, storedSkill.Enabled)
|
||||
}
|
||||
|
||||
type lifecycleAgentRepo struct {
|
||||
interfaces.CustomAgentRepository
|
||||
rows map[string]*types.CustomAgent
|
||||
writes int
|
||||
}
|
||||
|
||||
func (r *lifecycleAgentRepo) CreateAgent(_ context.Context, a *types.CustomAgent) error {
|
||||
r.writes++
|
||||
r.rows[a.ID] = a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *lifecycleAgentRepo) GetAgentByID(_ context.Context, id string, tenant uint64) (*types.CustomAgent, error) {
|
||||
a := r.rows[id]
|
||||
if a == nil || a.TenantID != tenant {
|
||||
return nil, repository.ErrCustomAgentNotFound
|
||||
}
|
||||
copy := *a
|
||||
return ©, nil
|
||||
}
|
||||
func (r *lifecycleAgentRepo) ListAgentsByTenantID(_ context.Context, tenant uint64) ([]*types.CustomAgent, error) {
|
||||
out := []*types.CustomAgent{}
|
||||
for _, a := range r.rows {
|
||||
if a.TenantID == tenant {
|
||||
copy := *a
|
||||
out = append(out, ©)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (r *lifecycleAgentRepo) UpdateAgent(_ context.Context, a *types.CustomAgent) error {
|
||||
r.writes++
|
||||
r.rows[a.ID] = a
|
||||
return nil
|
||||
}
|
||||
func (r *lifecycleAgentRepo) DeleteAgent(_ context.Context, id string, _ uint64) error {
|
||||
r.writes++
|
||||
delete(r.rows, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNativeAgentLifecycleReadWriteAndTenantIsolation(t *testing.T) {
|
||||
repo := &lifecycleAgentRepo{rows: map[string]*types.CustomAgent{
|
||||
"private": {ID: "private", TenantID: 1, CreatedBy: "alice", Name: "Private", Visibility: "private", Enabled: true},
|
||||
"global": {ID: "global", TenantID: 1, CreatedBy: "alice", Name: "Global", Visibility: "global", Enabled: true},
|
||||
"disabled": {ID: "disabled", TenantID: 1, CreatedBy: "alice", Name: "Disabled", Visibility: "global", Enabled: false},
|
||||
"legacy": {ID: "legacy", TenantID: 1, CreatedBy: "alice", Name: "Legacy"},
|
||||
}}
|
||||
s := &customAgentService{repo: repo}
|
||||
bob := instructionContext(1, "bob", types.TenantRoleContributor)
|
||||
for _, id := range []string{"private", "disabled"} {
|
||||
_, err := s.GetAgentByID(bob, id)
|
||||
require.ErrorIs(t, err, ErrAgentNotFound)
|
||||
}
|
||||
for _, id := range []string{"global", "legacy"} {
|
||||
a, err := s.GetAgentByID(bob, id)
|
||||
require.NoError(t, err)
|
||||
require.True(t, a.Enabled)
|
||||
}
|
||||
list, err := s.ListAgents(bob)
|
||||
require.NoError(t, err)
|
||||
ids := []string{}
|
||||
for _, a := range list {
|
||||
ids = append(ids, a.ID)
|
||||
}
|
||||
require.Contains(t, ids, "global")
|
||||
require.NotContains(t, ids, "private")
|
||||
require.NotContains(t, ids, "disabled")
|
||||
_, err = s.UpdateAgent(bob, &types.CustomAgent{ID: "global", Name: "stolen"})
|
||||
require.Error(t, err)
|
||||
require.Error(t, s.DeleteAgent(bob, "global"))
|
||||
_, err = s.CopyAgent(bob, "global")
|
||||
require.ErrorContains(t, err, "不能复制")
|
||||
require.Zero(t, repo.writes)
|
||||
shareService := &agentShareService{agentRepo: repo}
|
||||
_, err = shareService.ShareAgent(bob, "private", "organization", "bob", 1, types.OrgRoleViewer)
|
||||
require.ErrorIs(t, err, ErrNotAgentOwner, "private Agents cannot be exposed by creating an organization share")
|
||||
_, err = s.GetAgentByIDAndTenant(instructionContext(2, "admin", types.TenantRoleAdmin), "global", 1)
|
||||
require.ErrorIs(t, err, ErrAgentNotFound)
|
||||
alice := instructionContext(1, "alice", types.TenantRoleContributor)
|
||||
a, err := s.UpdateAgent(alice, &types.CustomAgent{ID: "disabled", Name: "renamed"})
|
||||
require.NoError(t, err)
|
||||
require.False(t, a.Enabled, "old clients must preserve lifecycle fields")
|
||||
require.NoError(t, s.DeleteAgent(alice, "private"))
|
||||
}
|
||||
|
||||
func TestInstructionCatalogCannotLeakThroughExecutableCatalogFallback(t *testing.T) {
|
||||
s, alice := instructionHarness(t)
|
||||
require.NoError(t, s.db.AutoMigrate(&types.TenantSkillEntity{}))
|
||||
skill, err := s.Import(alice, "secret.md", "", "", []byte("private body"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.db.Create(&types.TenantSkillEntity{ID: "installation", TenantID: 1, CatalogID: skill.ID, Name: "secret", Enabled: true}).Error)
|
||||
service := NewTenantSkillService(repository.NewTenantSkillRepository(s.db), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
bob := instructionContext(1, "bob", types.TenantRoleContributor)
|
||||
list, err := service.ListCatalog(bob, 1)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, list)
|
||||
for _, id := range []string{skill.ID, "installation"} {
|
||||
_, err = service.resolveCatalog(bob, 1, id)
|
||||
require.Error(t, err)
|
||||
}
|
||||
list, err = service.ListCatalog(instructionContext(2, "admin", types.TenantRoleAdmin), 1)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, list)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// auditLogService is the high-level wrapper around AuditLogRepository.
|
||||
// It owns:
|
||||
// - timestamp defaulting (Log fills CreatedAt if zero so callers
|
||||
// don't have to).
|
||||
// - the 1-minute sliding-window dedup that LogDenied applies to keep
|
||||
// a probing client from filling the audit_logs table.
|
||||
//
|
||||
// The service is intentionally nil-tolerant when consumed: the
|
||||
// tenant_member service holds it as an optional field, so a future
|
||||
// container reshuffle that constructs tenant_member before audit_log
|
||||
// won't crash. Callers should still aim to inject a real instance —
|
||||
// the nil path is a degraded mode, not a default.
|
||||
type auditLogService struct {
|
||||
repo interfaces.AuditLogRepository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewAuditLogService constructs the production service.
|
||||
func NewAuditLogService(repo interfaces.AuditLogRepository) interfaces.AuditLogService {
|
||||
return &auditLogService{repo: repo, now: time.Now}
|
||||
}
|
||||
|
||||
// denyDedupWindow caps how often LogDenied will write a durable row
|
||||
// for the same (tenant, actor, path, action) tuple. 1 minute is
|
||||
// short enough that bursts across distinct paths still each show up
|
||||
// in the table (one row per path per minute), long enough that a
|
||||
// single hammered endpoint at 100 RPS produces 1 row/minute, not 6000.
|
||||
const denyDedupWindow = 1 * time.Minute
|
||||
|
||||
// Log is the canonical write path. The repo's Create defaults
|
||||
// CreatedAt at the SQL level, but we also fill it here so tests and
|
||||
// callers that read entry.CreatedAt right after Log() get a sensible
|
||||
// value without round-tripping through the database.
|
||||
func (s *auditLogService) Log(ctx context.Context, entry *types.AuditLog) error {
|
||||
if entry == nil {
|
||||
return fmt.Errorf("audit log: nil entry")
|
||||
}
|
||||
if entry.Action == "" {
|
||||
return fmt.Errorf("audit log: action is required")
|
||||
}
|
||||
if entry.Outcome == "" {
|
||||
entry.Outcome = types.AuditOutcomeSuccess
|
||||
}
|
||||
if entry.CreatedAt.IsZero() {
|
||||
entry.CreatedAt = s.now()
|
||||
}
|
||||
if err := s.repo.Create(ctx, entry); err != nil {
|
||||
// Log loudly but do NOT propagate the error to the caller in
|
||||
// the production wiring (see callers in tenant_member service
|
||||
// and rbac middleware — both ignore the return). Audit failure
|
||||
// must never break the underlying business operation.
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"audit_action": entry.Action,
|
||||
"audit_target": entry.TargetID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogDenied records a middleware-level rejection. Subject to a
|
||||
// 1-minute sliding-window dedup keyed by
|
||||
// (tenant_id, actor_user_id, action, request_path) so a probing
|
||||
// client cannot flood the table.
|
||||
//
|
||||
// The non-durable advisory log line `[rbac] role insufficient: ...`
|
||||
// continues to fire on every reject (in middleware/rbac.go) — the
|
||||
// dedup only suppresses durable writes, not stderr observability.
|
||||
func (s *auditLogService) LogDenied(
|
||||
ctx context.Context,
|
||||
c *gin.Context,
|
||||
tenantID uint64,
|
||||
actorUserID, actorRole string,
|
||||
requiredRole types.TenantRole,
|
||||
) error {
|
||||
rawPath := ""
|
||||
requestMethod := ""
|
||||
// dedupPath keys both the sliding-window dedup AND the persisted
|
||||
// request_path column. We prefer the route TEMPLATE (gin's
|
||||
// c.FullPath, e.g. "/api/v1/knowledge-bases/:id") over the raw URL:
|
||||
// without this, an attacker iterating UUIDs in the URL produces a
|
||||
// fresh dedup key per request, defeating the window and ballooning
|
||||
// audit_logs. The raw URL is preserved inside the Details JSON for
|
||||
// forensics, so we don't lose "which resource was probed".
|
||||
dedupPath := ""
|
||||
if c != nil && c.Request != nil {
|
||||
rawPath = c.Request.URL.Path
|
||||
requestMethod = c.Request.Method
|
||||
dedupPath = c.FullPath()
|
||||
if dedupPath == "" {
|
||||
// No matched route (e.g. 404 path that still hit the
|
||||
// middleware via a catch-all). Fall back to the raw path so
|
||||
// the row remains non-empty for the audit reader.
|
||||
dedupPath = rawPath
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup probe: skip the durable write if this exact tuple already
|
||||
// has a row in the trailing window. Failure here is non-fatal —
|
||||
// degraded behaviour is "write a duplicate" which is preferable to
|
||||
// "skip the audit because the count failed".
|
||||
since := s.now().Add(-denyDedupWindow)
|
||||
if n, err := s.repo.CountSinceForDedup(
|
||||
ctx, tenantID, actorUserID, types.AuditActionAccessDenied, dedupPath, since,
|
||||
); err == nil && n > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
detailsMap := map[string]string{"required_role": string(requiredRole)}
|
||||
if rawPath != "" && rawPath != dedupPath {
|
||||
detailsMap["raw_path"] = rawPath
|
||||
}
|
||||
details, _ := json.Marshal(detailsMap)
|
||||
return s.Log(ctx, &types.AuditLog{
|
||||
TenantID: tenantID,
|
||||
ActorUserID: actorUserID,
|
||||
ActorRole: actorRole,
|
||||
Action: types.AuditActionAccessDenied,
|
||||
RequestPath: dedupPath,
|
||||
RequestMethod: requestMethod,
|
||||
Outcome: types.AuditOutcomeDenied,
|
||||
Details: types.JSON(details),
|
||||
})
|
||||
}
|
||||
|
||||
// List proxies to the repository. The handler layer applies the
|
||||
// PathTenantMatch + Admin guard before this is reached, so we don't
|
||||
// re-check tenant scope here.
|
||||
func (s *auditLogService) List(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
q *interfaces.AuditLogQuery,
|
||||
) ([]*types.AuditLog, error) {
|
||||
return s.repo.List(ctx, tenantID, q)
|
||||
}
|
||||
|
||||
// Purge deletes rows whose created_at is strictly older than
|
||||
// `retentionDays` ago. retentionDays <= 0 short-circuits — operators
|
||||
// who configured no retention pay zero database round-trips.
|
||||
//
|
||||
// The cutoff is computed off the service's clock (s.now) so tests
|
||||
// can drive deterministic horizons without touching wall time.
|
||||
//
|
||||
// We intentionally do NOT batch the DELETE: at the volumes audit_logs
|
||||
// realistically reaches in a 24h window, a single DELETE-with-index
|
||||
// finishes in well under a second on Postgres. If the table ever
|
||||
// grows large enough that a single sweep blocks vacuum, the repo
|
||||
// helper is the place to add LIMIT-style chunking — the service stays
|
||||
// simple.
|
||||
func (s *auditLogService) Purge(ctx context.Context, retentionDays int) (int64, error) {
|
||||
if retentionDays <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
cutoff := s.now().Add(-time.Duration(retentionDays) * 24 * time.Hour)
|
||||
return s.repo.DeleteOlderThan(ctx, cutoff)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// AuditLogRetentionRunner sweeps audit_logs once a day, deleting rows
|
||||
// older than `retentionDays`. It is a small, self-contained
|
||||
// background goroutine — no robfig/cron, no asynq — because retention
|
||||
// has no wall-clock alignment requirement: we just need "approximately
|
||||
// daily, eventually". A bare time.Ticker keeps the dependency surface
|
||||
// minimal.
|
||||
//
|
||||
// retentionDays <= 0 makes Start a no-op; this is the configured way
|
||||
// to disable retention entirely. Validation happens at config-load
|
||||
// time so by the time we're here a non-positive value is intentional.
|
||||
type AuditLogRetentionRunner struct {
|
||||
svc interfaces.AuditLogService
|
||||
retentionDays int
|
||||
interval time.Duration
|
||||
|
||||
startOnce sync.Once
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
// started is set inside startOnce.Do BEFORE doneCh is wired to a
|
||||
// goroutine, so Stop() can tell "Start was never called" apart from
|
||||
// "Start is running" without blocking on doneCh. Without this, a
|
||||
// runner that was constructed but never Start()'d (early container
|
||||
// init failure, test setup that skips Start) would deadlock Stop()
|
||||
// on a doneCh nobody ever closes.
|
||||
started atomic.Bool
|
||||
}
|
||||
|
||||
// auditLogPurgeInterval is the gap between sweeps. 24h is enough for
|
||||
// a per-day retention horizon — the cutoff moves by 24h between runs
|
||||
// so each sweep deletes one day's worth of rolled-off rows. Shortening
|
||||
// this would just cause empty sweeps; lengthening it would pile up
|
||||
// stale rows for a day.
|
||||
const auditLogPurgeInterval = 24 * time.Hour
|
||||
|
||||
// auditLogPurgeStartupDelay holds the very first sweep until shortly
|
||||
// after boot so we don't compete with migration-up traffic or other
|
||||
// startup work. Long enough that the first DELETE doesn't fight the
|
||||
// initial request flood; short enough that operators see the sweep
|
||||
// fire on the same day they restart.
|
||||
const auditLogPurgeStartupDelay = 10 * time.Minute
|
||||
|
||||
// NewAuditLogRetentionRunner constructs the runner with production
|
||||
// defaults. retention_days is read from the config; passing the full
|
||||
// *config.Config (rather than just an int) keeps the dig wiring trivial
|
||||
// — it's the same shape as every other config-aware constructor in
|
||||
// the container. The constructor only validates inputs; nothing fires
|
||||
// until Start is called.
|
||||
func NewAuditLogRetentionRunner(
|
||||
cfg *config.Config, svc interfaces.AuditLogService,
|
||||
) *AuditLogRetentionRunner {
|
||||
retentionDays := 0
|
||||
if cfg != nil && cfg.Audit != nil {
|
||||
retentionDays = cfg.Audit.RetentionDays
|
||||
}
|
||||
return &AuditLogRetentionRunner{
|
||||
svc: svc,
|
||||
retentionDays: retentionDays,
|
||||
interval: auditLogPurgeInterval,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start spins up the background goroutine. Calling it more than once
|
||||
// is a no-op (sync.Once), so container wiring that mistakenly invokes
|
||||
// us twice doesn't double-purge. retentionDays <= 0 means the runner
|
||||
// stays dormant — Stop will still complete cleanly.
|
||||
func (r *AuditLogRetentionRunner) Start(ctx context.Context) {
|
||||
if r == nil || r.svc == nil {
|
||||
return
|
||||
}
|
||||
r.startOnce.Do(func() {
|
||||
r.started.Store(true)
|
||||
if r.retentionDays <= 0 {
|
||||
logger.Infof(ctx,
|
||||
"[audit-retention] disabled (retention_days=%d)", r.retentionDays)
|
||||
close(r.doneCh)
|
||||
return
|
||||
}
|
||||
logger.Infof(ctx,
|
||||
"[audit-retention] starting daily sweep: retention_days=%d interval=%s",
|
||||
r.retentionDays, r.interval)
|
||||
go r.loop()
|
||||
})
|
||||
}
|
||||
|
||||
// Stop signals the loop to exit and blocks until it returns. Idempotent.
|
||||
// If Start was never called, Stop returns immediately (no doneCh to
|
||||
// wait on — see the `started` flag in the struct comment).
|
||||
func (r *AuditLogRetentionRunner) Stop() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
if !r.started.Load() {
|
||||
return
|
||||
}
|
||||
r.stopOnce.Do(func() {
|
||||
close(r.stopCh)
|
||||
})
|
||||
<-r.doneCh
|
||||
}
|
||||
|
||||
// loop runs the actual sweep cadence. Uses a fresh context.Background
|
||||
// per iteration because the request-scoped ctx from Start would be
|
||||
// cancelled the moment Start's caller returned — and that caller is
|
||||
// container init.
|
||||
func (r *AuditLogRetentionRunner) loop() {
|
||||
defer close(r.doneCh)
|
||||
|
||||
startupTimer := time.NewTimer(auditLogPurgeStartupDelay)
|
||||
defer startupTimer.Stop()
|
||||
select {
|
||||
case <-startupTimer.C:
|
||||
case <-r.stopCh:
|
||||
return
|
||||
}
|
||||
|
||||
r.runOnce()
|
||||
|
||||
ticker := time.NewTicker(r.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
r.runOnce()
|
||||
case <-r.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runOnce performs a single sweep. The DB call is given a generous
|
||||
// timeout (30 s) so a stuck connection doesn't hold the goroutine
|
||||
// hostage forever — if the sweep doesn't finish in 30 s we'll log
|
||||
// and try again 24 h later. Errors are logged at WARN, not ERROR,
|
||||
// because the table just keeps growing one more day; nothing breaks.
|
||||
func (r *AuditLogRetentionRunner) runOnce() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
deleted, err := r.svc.Purge(ctx, r.retentionDays)
|
||||
if err != nil {
|
||||
logger.Warnf(ctx,
|
||||
"[audit-retention] sweep failed: retention_days=%d err=%v",
|
||||
r.retentionDays, err)
|
||||
return
|
||||
}
|
||||
if deleted > 0 {
|
||||
logger.Infof(ctx,
|
||||
"[audit-retention] sweep complete: deleted=%d retention_days=%d",
|
||||
deleted, r.retentionDays)
|
||||
} else {
|
||||
logger.Debugf(ctx,
|
||||
"[audit-retention] sweep complete: deleted=0 retention_days=%d",
|
||||
r.retentionDays)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// stubAuditRepoForRetention captures DeleteOlderThan calls. We embed
|
||||
// the interface so any unstubbed method nil-panics — that's the same
|
||||
// contract-drift signal stubAuditRepo uses, kept consistent across
|
||||
// the package's audit log tests.
|
||||
type stubAuditRepoForRetention struct {
|
||||
interfaces.AuditLogRepository
|
||||
|
||||
mu sync.Mutex
|
||||
calls []time.Time
|
||||
deleted int64
|
||||
deleteError error
|
||||
}
|
||||
|
||||
func (s *stubAuditRepoForRetention) DeleteOlderThan(_ context.Context, cutoff time.Time) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.calls = append(s.calls, cutoff)
|
||||
if s.deleteError != nil {
|
||||
return 0, s.deleteError
|
||||
}
|
||||
return s.deleted, nil
|
||||
}
|
||||
|
||||
func TestAuditLog_Purge_NoOpWhenRetentionDisabled(t *testing.T) {
|
||||
// retention_days <= 0 must short-circuit before hitting the repo.
|
||||
// Otherwise an "off" config would still issue a daily DELETE on
|
||||
// every column < cutoff, which silently nukes every row when
|
||||
// cutoff = now().
|
||||
repo := &stubAuditRepoForRetention{}
|
||||
clock := &fakeClock{t: time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)}
|
||||
svc := &auditLogService{repo: repo, now: clock.Now}
|
||||
|
||||
for _, days := range []int{0, -1, -90} {
|
||||
deleted, err := svc.Purge(context.Background(), days)
|
||||
if err != nil {
|
||||
t.Fatalf("Purge(%d) unexpected error: %v", days, err)
|
||||
}
|
||||
if deleted != 0 {
|
||||
t.Fatalf("Purge(%d) returned non-zero count: %d", days, deleted)
|
||||
}
|
||||
}
|
||||
if len(repo.calls) != 0 {
|
||||
t.Fatalf("expected zero repo.DeleteOlderThan calls, got %d", len(repo.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_Purge_UsesClockMinusRetention(t *testing.T) {
|
||||
// The cutoff fed to the repo must be exactly retention_days × 24h
|
||||
// before the service's clock. Off-by-day errors would silently
|
||||
// retain too much (table grows) or delete too much (data loss).
|
||||
repo := &stubAuditRepoForRetention{deleted: 42}
|
||||
clock := &fakeClock{t: time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)}
|
||||
svc := &auditLogService{repo: repo, now: clock.Now}
|
||||
|
||||
deleted, err := svc.Purge(context.Background(), 90)
|
||||
if err != nil {
|
||||
t.Fatalf("Purge: %v", err)
|
||||
}
|
||||
if deleted != 42 {
|
||||
t.Fatalf("expected delete count 42 propagated, got %d", deleted)
|
||||
}
|
||||
|
||||
if len(repo.calls) != 1 {
|
||||
t.Fatalf("expected 1 repo call, got %d", len(repo.calls))
|
||||
}
|
||||
wantCutoff := clock.Now().Add(-90 * 24 * time.Hour)
|
||||
if !repo.calls[0].Equal(wantCutoff) {
|
||||
t.Fatalf("cutoff: want %v, got %v", wantCutoff, repo.calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_Purge_PropagatesRepoError(t *testing.T) {
|
||||
// Retention failures must surface to the runner, which logs them
|
||||
// at WARN. Silently swallowing the error would mask a degraded DB
|
||||
// for days because the next sweep is 24h away.
|
||||
repo := &stubAuditRepoForRetention{deleteError: errors.New("connection lost")}
|
||||
clock := &fakeClock{t: time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)}
|
||||
svc := &auditLogService{repo: repo, now: clock.Now}
|
||||
|
||||
if _, err := svc.Purge(context.Background(), 30); err == nil {
|
||||
t.Fatalf("expected error to propagate from repo")
|
||||
}
|
||||
}
|
||||
|
||||
// purgeCountingService is a tiny in-test AuditLogService implementation
|
||||
// that just counts Purge calls. The runner's loop is the only thing
|
||||
// under test here, so we can ignore Log / LogDenied / List entirely.
|
||||
type purgeCountingService struct {
|
||||
interfaces.AuditLogService
|
||||
calls atomic.Int64
|
||||
}
|
||||
|
||||
func (p *purgeCountingService) Purge(_ context.Context, _ int) (int64, error) {
|
||||
p.calls.Add(1)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func TestAuditLogRetentionRunner_StartIsNoOpWhenDisabled(t *testing.T) {
|
||||
// retention_days <= 0 keeps the goroutine asleep — Start logs a
|
||||
// "disabled" line and Stop returns immediately. This is the
|
||||
// configured way to turn retention off, and the runner must not
|
||||
// kick a goroutine for it.
|
||||
svc := &purgeCountingService{}
|
||||
r := &AuditLogRetentionRunner{
|
||||
svc: svc,
|
||||
retentionDays: 0,
|
||||
interval: time.Millisecond,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
r.Start(context.Background())
|
||||
r.Stop()
|
||||
if got := svc.calls.Load(); got != 0 {
|
||||
t.Fatalf("expected 0 Purge calls when disabled, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLogRetentionRunner_StopIsIdempotent(t *testing.T) {
|
||||
// Calling Stop twice must not panic — container shutdown ordering
|
||||
// can race ResourceCleaner with other shutdown hooks, and we'd
|
||||
// rather no-op than crash on the second call.
|
||||
svc := &purgeCountingService{}
|
||||
r := &AuditLogRetentionRunner{
|
||||
svc: svc,
|
||||
retentionDays: 0,
|
||||
interval: time.Millisecond,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
r.Start(context.Background())
|
||||
r.Stop()
|
||||
r.Stop()
|
||||
}
|
||||
|
||||
func TestAuditLogRetentionRunner_StartIsIdempotent(t *testing.T) {
|
||||
// Container init that mistakenly invokes Start twice must not
|
||||
// double-fire the loop. sync.Once guards this; the test pins the
|
||||
// invariant.
|
||||
svc := &purgeCountingService{}
|
||||
r := &AuditLogRetentionRunner{
|
||||
svc: svc,
|
||||
retentionDays: 0,
|
||||
interval: time.Millisecond,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
r.Start(context.Background())
|
||||
r.Start(context.Background())
|
||||
r.Stop()
|
||||
}
|
||||
|
||||
func TestAuditLogRetentionRunner_NilSvcShortCircuits(t *testing.T) {
|
||||
// Defensive: a misconfigured container (audit service couldn't
|
||||
// be constructed) must not crash the app. Start with nil svc is
|
||||
// a no-op, and the subsequent Stop must NOT hang on a doneCh
|
||||
// nobody ever closed (regression: when startOnce.Do returned early
|
||||
// without closing doneCh, Stop would block forever, deadlocking
|
||||
// graceful shutdown).
|
||||
r := &AuditLogRetentionRunner{
|
||||
retentionDays: 90,
|
||||
interval: time.Millisecond,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
r.Start(context.Background())
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
r.Stop()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Stop() hung after Start() short-circuited on nil svc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLogRetentionRunner_StopBeforeStart(t *testing.T) {
|
||||
// Container teardown ordering can run Stop before Start ever fires
|
||||
// (early init failure, test cleanup). The runner must treat this
|
||||
// as a no-op rather than blocking on doneCh.
|
||||
r := &AuditLogRetentionRunner{
|
||||
svc: &purgeCountingService{},
|
||||
retentionDays: 90,
|
||||
interval: time.Millisecond,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
r.Stop()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Stop() hung when called before Start()")
|
||||
}
|
||||
}
|
||||
|
||||
// retentionRunnerWithImmediateStartup builds a runner whose startup
|
||||
// delay has already elapsed, so the loop runs Purge immediately. We
|
||||
// can't expose that as a public knob (production should not skip the
|
||||
// startup grace window), so the test reaches inside the package to
|
||||
// build the runner with a custom interval.
|
||||
func retentionRunnerWithImmediateStartup(svc interfaces.AuditLogService, days int) *AuditLogRetentionRunner {
|
||||
return &AuditLogRetentionRunner{
|
||||
svc: svc,
|
||||
retentionDays: days,
|
||||
interval: 30 * time.Millisecond,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// runLoopWithoutStartupDelay drives the runner's loop directly, skipping
|
||||
// the 10-minute startup pause that production uses to stay out of the
|
||||
// way of boot traffic. Tests need to fire the sweep immediately.
|
||||
func (r *AuditLogRetentionRunner) runLoopWithoutStartupDelay() {
|
||||
defer close(r.doneCh)
|
||||
r.runOnce()
|
||||
ticker := time.NewTicker(r.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
r.runOnce()
|
||||
case <-r.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLogRetentionRunner_PurgesOnTickerCadence(t *testing.T) {
|
||||
// The actual cadence test: with the startup delay collapsed and
|
||||
// the interval shrunk to 30ms, we expect at least 2 Purge calls
|
||||
// over a 100ms window. This pins the headline behaviour ("yes,
|
||||
// the goroutine actually fires Purge over time") without coupling
|
||||
// to a specific count, which would be flaky on a slow CI runner.
|
||||
svc := &purgeCountingService{}
|
||||
r := retentionRunnerWithImmediateStartup(svc, 30)
|
||||
|
||||
go r.runLoopWithoutStartupDelay()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
r.Stop()
|
||||
|
||||
if got := svc.calls.Load(); got < 2 {
|
||||
t.Fatalf("expected >=2 Purge calls in 100ms with 30ms interval, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLogRetentionRunner_RunOnceLogsButDoesNotPanicOnError(t *testing.T) {
|
||||
// runOnce must swallow Purge errors — a stuck DB shouldn't propagate
|
||||
// a panic up out of the goroutine and crash the app. The behaviour
|
||||
// is "log at WARN and try again next tick".
|
||||
repo := &stubAuditRepoForRetention{deleteError: errors.New("simulated")}
|
||||
clock := &fakeClock{t: time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)}
|
||||
svc := &auditLogService{repo: repo, now: clock.Now}
|
||||
|
||||
r := retentionRunnerWithImmediateStartup(svc, 30)
|
||||
r.runOnce() // must not panic
|
||||
if got := len(repo.calls); got != 1 {
|
||||
t.Fatalf("expected runOnce to call repo once, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the test stubs satisfy the production type: if the interface
|
||||
// drifts, this assignment will fail to compile and tell us immediately.
|
||||
var _ interfaces.AuditLogRepository = (*stubAuditRepo)(nil)
|
||||
var _ interfaces.AuditLogRepository = (*stubAuditRepoForRetention)(nil)
|
||||
|
||||
// Sanity check the audit entry struct's fields stay attached to the
|
||||
// retention path — guards against a refactor that drops CreatedAt
|
||||
// from the model and silently breaks the cutoff filter.
|
||||
func TestAuditLogModel_HasCreatedAtField(t *testing.T) {
|
||||
entry := types.AuditLog{}
|
||||
entry.CreatedAt = time.Now()
|
||||
if entry.CreatedAt.IsZero() {
|
||||
t.Fatal("AuditLog.CreatedAt must be assignable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// stubAuditRepo collects Create calls and answers CountSinceForDedup
|
||||
// from the in-memory state. Embeds the interface so any unstubbed
|
||||
// method will nil-panic, surfacing a contract drift loudly instead of
|
||||
// silently returning zero-values.
|
||||
type stubAuditRepo struct {
|
||||
interfaces.AuditLogRepository
|
||||
|
||||
mu sync.Mutex
|
||||
created []*types.AuditLog
|
||||
// countErr lets a test inject a transient lookup failure.
|
||||
countErr error
|
||||
}
|
||||
|
||||
func (s *stubAuditRepo) Create(_ context.Context, entry *types.AuditLog) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.created = append(s.created, entry)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAuditRepo) CountSinceForDedup(
|
||||
_ context.Context, tenantID uint64, actorUserID string,
|
||||
action types.AuditAction, requestPath string, since time.Time,
|
||||
) (int64, error) {
|
||||
if s.countErr != nil {
|
||||
return 0, s.countErr
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var n int64
|
||||
for _, e := range s.created {
|
||||
if e.TenantID == tenantID &&
|
||||
e.ActorUserID == actorUserID &&
|
||||
e.Action == action &&
|
||||
e.RequestPath == requestPath &&
|
||||
!e.CreatedAt.Before(since) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// fakeClock returns a controllable time source so the dedup-window
|
||||
// tests can simulate "1 minute later" without sleeping.
|
||||
type fakeClock struct{ t time.Time }
|
||||
|
||||
func (f *fakeClock) Now() time.Time { return f.t }
|
||||
func (f *fakeClock) Advance(by time.Duration) { f.t = f.t.Add(by) }
|
||||
|
||||
func newSvcForTest() (*auditLogService, *stubAuditRepo, *fakeClock) {
|
||||
clock := &fakeClock{t: time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)}
|
||||
repo := &stubAuditRepo{}
|
||||
svc := &auditLogService{repo: repo, now: clock.Now}
|
||||
return svc, repo, clock
|
||||
}
|
||||
|
||||
func newDeniedCtx(t *testing.T, method, path string) *gin.Context {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(method, path, nil)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestAuditLog_Log_FillsCreatedAtAndOutcome(t *testing.T) {
|
||||
svc, repo, clock := newSvcForTest()
|
||||
entry := &types.AuditLog{
|
||||
TenantID: 7,
|
||||
Action: types.AuditActionMemberAdded,
|
||||
// CreatedAt left zero, Outcome left empty
|
||||
}
|
||||
if err := svc.Log(context.Background(), entry); err != nil {
|
||||
t.Fatalf("Log: %v", err)
|
||||
}
|
||||
if len(repo.created) != 1 {
|
||||
t.Fatalf("expected 1 written entry, got %d", len(repo.created))
|
||||
}
|
||||
if !entry.CreatedAt.Equal(clock.Now()) {
|
||||
t.Fatalf("expected CreatedAt to default to clock time, got %v", entry.CreatedAt)
|
||||
}
|
||||
if entry.Outcome != types.AuditOutcomeSuccess {
|
||||
t.Fatalf("expected Outcome to default to success, got %q", entry.Outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_Log_RejectsEmptyAction(t *testing.T) {
|
||||
// Schema requires action; the service guards the contract upfront so
|
||||
// callers get a clean error instead of a constraint violation later.
|
||||
svc, _, _ := newSvcForTest()
|
||||
err := svc.Log(context.Background(), &types.AuditLog{TenantID: 7})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when Action is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_LogDenied_DedupesRepeatedRejectsWithinWindow(t *testing.T) {
|
||||
// First denied write hits the table; second within the window does
|
||||
// NOT — the dedup primitive is the headline durability guarantee
|
||||
// against probing clients filling the audit table at line rate.
|
||||
svc, repo, _ := newSvcForTest()
|
||||
c := newDeniedCtx(t, "PUT", "/api/v1/tenants/7")
|
||||
|
||||
if err := svc.LogDenied(context.Background(), c, 7, "u-viewer", "viewer", types.TenantRoleAdmin); err != nil {
|
||||
t.Fatalf("LogDenied: %v", err)
|
||||
}
|
||||
if err := svc.LogDenied(context.Background(), c, 7, "u-viewer", "viewer", types.TenantRoleAdmin); err != nil {
|
||||
t.Fatalf("LogDenied second call: %v", err)
|
||||
}
|
||||
if len(repo.created) != 1 {
|
||||
t.Fatalf("expected dedup to drop second write, got %d entries", len(repo.created))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_LogDenied_WritesAgainAfterWindowExpires(t *testing.T) {
|
||||
// The dedup is a sliding window, not a once-per-tuple lock — once
|
||||
// the trailing window is empty, the next denied call must record.
|
||||
svc, repo, clock := newSvcForTest()
|
||||
c := newDeniedCtx(t, "PUT", "/api/v1/tenants/7")
|
||||
|
||||
_ = svc.LogDenied(context.Background(), c, 7, "u-viewer", "viewer", types.TenantRoleAdmin)
|
||||
clock.Advance(denyDedupWindow + time.Second)
|
||||
_ = svc.LogDenied(context.Background(), c, 7, "u-viewer", "viewer", types.TenantRoleAdmin)
|
||||
|
||||
if len(repo.created) != 2 {
|
||||
t.Fatalf("expected window-expiry to allow second write, got %d entries", len(repo.created))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_LogDenied_DedupIsPerActorAndPath(t *testing.T) {
|
||||
// Two distinct actors hitting the same endpoint, or the same actor
|
||||
// hitting two distinct endpoints, must each record. The dedup key
|
||||
// is (tenant, actor, action, path) — anything else collides.
|
||||
svc, repo, _ := newSvcForTest()
|
||||
c1 := newDeniedCtx(t, "PUT", "/api/v1/tenants/7")
|
||||
c2 := newDeniedCtx(t, "PUT", "/api/v1/agents/abc")
|
||||
|
||||
_ = svc.LogDenied(context.Background(), c1, 7, "u-viewer-a", "viewer", types.TenantRoleAdmin)
|
||||
_ = svc.LogDenied(context.Background(), c1, 7, "u-viewer-b", "viewer", types.TenantRoleAdmin)
|
||||
_ = svc.LogDenied(context.Background(), c2, 7, "u-viewer-a", "viewer", types.TenantRoleAdmin)
|
||||
|
||||
if len(repo.created) != 3 {
|
||||
t.Fatalf("expected 3 distinct (actor,path) writes, got %d", len(repo.created))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLog_LogDenied_DegradesGracefullyOnDedupLookupError(t *testing.T) {
|
||||
// If the dedup count returns an error (DB hiccup, transient), we
|
||||
// must NOT silently skip the audit row. Better to write a
|
||||
// duplicate than to lose a denied event during incident response.
|
||||
svc, repo, _ := newSvcForTest()
|
||||
repo.countErr = errors.New("transient")
|
||||
c := newDeniedCtx(t, "PUT", "/api/v1/tenants/7")
|
||||
|
||||
if err := svc.LogDenied(context.Background(), c, 7, "u-viewer", "viewer", types.TenantRoleAdmin); err != nil {
|
||||
t.Fatalf("LogDenied with dedup error: %v", err)
|
||||
}
|
||||
if len(repo.created) != 1 {
|
||||
t.Fatalf("expected fallthrough write on dedup error, got %d entries", len(repo.created))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginChatCompletion implements chat completion functionality
|
||||
// as a plugin that can be registered to EventManager
|
||||
type PluginChatCompletion struct {
|
||||
modelService interfaces.ModelService // Interface for model operations
|
||||
}
|
||||
|
||||
// NewPluginChatCompletion creates a new PluginChatCompletion instance
|
||||
// and registers it with the EventManager
|
||||
func NewPluginChatCompletion(eventManager *EventManager, modelService interfaces.ModelService) *PluginChatCompletion {
|
||||
res := &PluginChatCompletion{
|
||||
modelService: modelService,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginChatCompletion) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHAT_COMPLETION}
|
||||
}
|
||||
|
||||
// OnEvent handles chat completion events
|
||||
// It prepares the chat model, messages, and calls the model to generate responses
|
||||
func (p *PluginChatCompletion) OnEvent(
|
||||
ctx context.Context, eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
pipelineInfo(ctx, "Completion", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_question": chatManage.UserContent,
|
||||
"history_rounds": len(chatManage.History),
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
})
|
||||
|
||||
// Prepare chat model and options
|
||||
chatModel, opt, err := prepareChatModel(ctx, p.modelService, chatManage)
|
||||
if err != nil {
|
||||
return ErrGetChatModel.WithError(err)
|
||||
}
|
||||
|
||||
// Prepare messages including conversation history
|
||||
pipelineInfo(ctx, "Completion", "messages_ready", map[string]interface{}{
|
||||
"message_count": len(chatManage.History) + 2,
|
||||
})
|
||||
chatMessages, modelContext := prepareMessagesWithModelContext(ctx, chatManage)
|
||||
chatMessages = modelContext.EncodeMessages(chatMessages)
|
||||
ctx = withPromptCacheMetadata(ctx, chatModel, chatMessages, opt, "knowledge_qa")
|
||||
|
||||
// Call the chat model to generate response
|
||||
pipelineInfo(ctx, "Completion", "model_call", map[string]interface{}{
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
})
|
||||
chatResponse, err := chatModel.Chat(ctx, chatMessages, opt)
|
||||
if err != nil {
|
||||
pipelineError(ctx, "Completion", "model_call", map[string]interface{}{
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ErrModelCall.WithError(err)
|
||||
}
|
||||
modelContext.DecodeResponse(chatResponse)
|
||||
if orphans := modelContext.OrphanResourceHandles(chatResponse.Content); len(orphans) > 0 {
|
||||
pipelineWarn(ctx, "Completion", "orphan_resource_handles", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"handles": orphans,
|
||||
})
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Completion", "output", map[string]interface{}{
|
||||
"answer_preview": chatResponse.Content,
|
||||
"finish_reason": chatResponse.FinishReason,
|
||||
"completion_tokens": chatResponse.Usage.CompletionTokens,
|
||||
"prompt_tokens": chatResponse.Usage.PromptTokens,
|
||||
})
|
||||
chatManage.ChatResponse = chatResponse
|
||||
return next()
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// PluginChatCompletionStream implements streaming chat completion functionality
|
||||
// as a plugin that can be registered to EventManager
|
||||
type PluginChatCompletionStream struct {
|
||||
modelService interfaces.ModelService // Interface for model operations
|
||||
}
|
||||
|
||||
// NewPluginChatCompletionStream creates a new PluginChatCompletionStream instance
|
||||
// and registers it with the EventManager
|
||||
func NewPluginChatCompletionStream(eventManager *EventManager,
|
||||
modelService interfaces.ModelService,
|
||||
) *PluginChatCompletionStream {
|
||||
res := &PluginChatCompletionStream{
|
||||
modelService: modelService,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginChatCompletionStream) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHAT_COMPLETION_STREAM}
|
||||
}
|
||||
|
||||
// OnEvent handles streaming chat completion events
|
||||
// It prepares the chat model, messages, and initiates streaming response
|
||||
func (p *PluginChatCompletionStream) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
pipelineInfo(ctx, "Stream", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_question": chatManage.UserContent,
|
||||
"history_rounds": len(chatManage.History),
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
})
|
||||
|
||||
// Prepare chat model and options
|
||||
chatModel, opt, err := prepareChatModel(ctx, p.modelService, chatManage)
|
||||
if err != nil {
|
||||
return ErrGetChatModel.WithError(err)
|
||||
}
|
||||
|
||||
// Prepare base messages without history
|
||||
|
||||
chatMessages, modelContext := prepareMessagesWithModelContext(ctx, chatManage)
|
||||
chatMessages = modelContext.EncodeMessages(chatMessages)
|
||||
ctx = withPromptCacheMetadata(ctx, chatModel, chatMessages, opt, "knowledge_qa")
|
||||
pipelineInfo(ctx, "Stream", "messages_ready", map[string]interface{}{
|
||||
"message_count": len(chatMessages),
|
||||
"system_prompt": chatMessages[0].Content,
|
||||
})
|
||||
pipelineInfo(ctx, "Stream", "user_message", map[string]interface{}{
|
||||
"content": chatMessages[len(chatMessages)-1].Content,
|
||||
})
|
||||
// EventBus is required for event-driven streaming
|
||||
if chatManage.EventBus == nil {
|
||||
pipelineError(ctx, "Stream", "eventbus_missing", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
return ErrModelCall.WithError(errors.New("EventBus is required for streaming"))
|
||||
}
|
||||
eventBus := chatManage.EventBus
|
||||
|
||||
pipelineInfo(ctx, "Stream", "eventbus_ready", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
|
||||
// Initiate streaming chat model call with independent context
|
||||
pipelineInfo(ctx, "Stream", "model_call", map[string]interface{}{
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
})
|
||||
responseChan, err := chatModel.ChatStream(ctx, chatMessages, opt)
|
||||
if err != nil {
|
||||
pipelineError(ctx, "Stream", "model_call", map[string]interface{}{
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ErrModelCall.WithError(err)
|
||||
}
|
||||
if responseChan == nil {
|
||||
pipelineError(ctx, "Stream", "model_call", map[string]interface{}{
|
||||
"chat_model": chatManage.ChatModelID,
|
||||
"error": "nil_channel",
|
||||
})
|
||||
return ErrModelCall.WithError(errors.New("chat stream returned nil channel"))
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Stream", "model_started", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
|
||||
// Start goroutine to consume channel and emit events directly.
|
||||
// reasoning_content is routed to EventAgentThought (SSE response_type=thinking)
|
||||
// and plain answer text to EventAgentFinalAnswer, matching the Agent pipeline.
|
||||
// The goroutine monitors ctx.Done() to avoid leaking when the context is cancelled
|
||||
// and the upstream channel is not closed promptly.
|
||||
go func() {
|
||||
answerDecoder := modelContext.StreamDecoder()
|
||||
thinkingDecoder := modelContext.StreamDecoder()
|
||||
thinkingID := fmt.Sprintf("%s-thinking", uuid.New().String()[:8])
|
||||
answerID := fmt.Sprintf("%s-answer", uuid.New().String()[:8])
|
||||
thinkingOpen := false
|
||||
answerCompleted := false
|
||||
|
||||
closeThinking := func() {
|
||||
if !thinkingOpen {
|
||||
return
|
||||
}
|
||||
eventBus.Emit(ctx, types.Event{
|
||||
ID: thinkingID,
|
||||
Type: types.EventType(event.EventAgentThought),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentThoughtData{
|
||||
Done: true,
|
||||
},
|
||||
})
|
||||
thinkingOpen = false
|
||||
}
|
||||
|
||||
// flushDecoders drains any handle suffix the stream decoders held back to
|
||||
// bridge references split across provider chunks. Both the normal close
|
||||
// and the cancellation path must call this, otherwise a resource
|
||||
// reference in flight at teardown is silently dropped (and never
|
||||
// persisted, since the assistant message is saved from these events).
|
||||
flushDecoders := func() {
|
||||
thinkingTail := thinkingDecoder.Flush()
|
||||
if thinkingTail != "" {
|
||||
_ = eventBus.Emit(ctx, types.Event{
|
||||
ID: thinkingID,
|
||||
Type: types.EventType(event.EventAgentThought),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentThoughtData{Content: thinkingTail},
|
||||
})
|
||||
}
|
||||
answerTail := answerDecoder.Flush()
|
||||
if answerTail != "" {
|
||||
_ = eventBus.Emit(ctx, types.Event{
|
||||
ID: answerID,
|
||||
Type: types.EventType(event.EventAgentFinalAnswer),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentFinalAnswerData{Content: answerTail},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
flushDecoders()
|
||||
closeThinking()
|
||||
pipelineInfo(ctx, "Stream", "context_cancelled", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
return
|
||||
|
||||
case response, ok := <-responseChan:
|
||||
if !ok {
|
||||
flushDecoders()
|
||||
closeThinking()
|
||||
pipelineInfo(ctx, "Stream", "channel_close", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if response.ResponseType == types.ResponseTypeError {
|
||||
pipelineError(ctx, "Stream", "stream_error", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"error": response.Content,
|
||||
})
|
||||
eventBus.Emit(ctx, types.Event{
|
||||
ID: fmt.Sprintf("%s-error", uuid.New().String()[:8]),
|
||||
Type: types.EventType(event.EventError),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.ErrorData{
|
||||
Error: response.Content,
|
||||
Stage: "chat_completion_stream",
|
||||
SessionID: chatManage.SessionID,
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if response.ResponseType == types.ResponseTypeThinking {
|
||||
response.Content = thinkingDecoder.Feed(response.Content)
|
||||
if response.Done {
|
||||
response.Content += thinkingDecoder.Flush()
|
||||
}
|
||||
if response.Content != "" {
|
||||
thinkingOpen = true
|
||||
eventBus.Emit(ctx, types.Event{
|
||||
ID: thinkingID,
|
||||
Type: types.EventType(event.EventAgentThought),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentThoughtData{
|
||||
Content: response.Content,
|
||||
Done: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
if response.Done {
|
||||
closeThinking()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if response.ResponseType == types.ResponseTypeAnswer {
|
||||
// Providers can emit a completion once for finish_reason and again
|
||||
// for their EOF sentinel. A final answer is a terminal event for a
|
||||
// single stream, so forwarding a later duplicate would put an answer
|
||||
// after the session's complete event.
|
||||
if answerCompleted {
|
||||
continue
|
||||
}
|
||||
response.Content = answerDecoder.Feed(response.Content)
|
||||
if response.Done {
|
||||
response.Content += answerDecoder.Flush()
|
||||
answerCompleted = true
|
||||
}
|
||||
closeThinking()
|
||||
eventBus.Emit(ctx, types.Event{
|
||||
ID: answerID,
|
||||
Type: types.EventType(event.EventAgentFinalAnswer),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentFinalAnswerData{
|
||||
Content: response.Content,
|
||||
Done: response.Done,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return next()
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// syncEventBus is a thread-safe recorder; the stream plugin emits from a
|
||||
// background goroutine so the test must guard concurrent appends.
|
||||
type syncEventBus struct {
|
||||
mu sync.Mutex
|
||||
events []types.Event
|
||||
}
|
||||
|
||||
func (b *syncEventBus) On(types.EventType, types.EventHandler) {}
|
||||
|
||||
func (b *syncEventBus) Emit(_ context.Context, evt types.Event) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.events = append(b.events, evt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *syncEventBus) finalAnswerContents() []string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
var out []string
|
||||
for _, evt := range b.events {
|
||||
if evt.Type != types.EventType(event.EventAgentFinalAnswer) {
|
||||
continue
|
||||
}
|
||||
if data, ok := evt.Data.(event.AgentFinalAnswerData); ok {
|
||||
out = append(out, data.Content)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// openStreamChat returns a buffered channel preloaded with chunks and never
|
||||
// closes it, so the stream plugin blocks on the channel until ctx is cancelled
|
||||
// — deterministically exercising the ctx.Done() branch.
|
||||
type openStreamChat struct {
|
||||
chunks []types.StreamResponse
|
||||
closeStream bool
|
||||
}
|
||||
|
||||
func (m *openStreamChat) Chat(context.Context, []chat.Message, *chat.ChatOptions) (*types.ChatResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *openStreamChat) ChatStream(
|
||||
context.Context, []chat.Message, *chat.ChatOptions,
|
||||
) (<-chan types.StreamResponse, error) {
|
||||
ch := make(chan types.StreamResponse, len(m.chunks))
|
||||
for _, c := range m.chunks {
|
||||
ch <- c
|
||||
}
|
||||
if m.closeStream {
|
||||
close(ch)
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (m *openStreamChat) GetModelName() string { return "mock" }
|
||||
func (m *openStreamChat) GetModelID() string { return "mock" }
|
||||
|
||||
// stubModelService only needs GetChatModel; the rest is unused for this test.
|
||||
type stubModelService struct {
|
||||
interfaces.ModelService
|
||||
model chat.Chat
|
||||
}
|
||||
|
||||
func (s *stubModelService) GetChatModel(context.Context, string) (chat.Chat, error) {
|
||||
return s.model, nil
|
||||
}
|
||||
|
||||
// TestStreamDropsIncompleteHandleOnCancel verifies that cancellation cannot
|
||||
// leak a model-context protocol fragment to the client.
|
||||
func TestStreamDropsIncompleteHandleOnCancel(t *testing.T) {
|
||||
const ref = "resource://AbCdEfGhIjKlMnOpQrStUv"
|
||||
bus := &syncEventBus{}
|
||||
model := &openStreamChat{chunks: []types.StreamResponse{
|
||||
// Ends with a partial alias prefix ("res://0"), so the stream decoder
|
||||
// holds it back waiting for the rest that never arrives before cancel.
|
||||
{ResponseType: types.ResponseTypeAnswer, Content: "hello res://0"},
|
||||
}}
|
||||
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.SessionID = "sess-cancel"
|
||||
chatManage.UserContent = ref // seeds the registry so res://0001 becomes a known alias
|
||||
chatManage.EventBus = bus
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
plugin := &PluginChatCompletionStream{modelService: &stubModelService{model: model}}
|
||||
require.Nil(t, plugin.OnEvent(ctx, types.CHAT_COMPLETION_STREAM, chatManage, func() *PluginError { return nil }))
|
||||
|
||||
// Wait until the pre-hold content has been emitted, then cancel.
|
||||
require.Eventually(t, func() bool {
|
||||
for _, c := range bus.finalAnswerContents() {
|
||||
if c == "hello " {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 2*time.Second, 5*time.Millisecond)
|
||||
|
||||
cancel()
|
||||
|
||||
// Give the cancellation path time to flush, then assert that only the
|
||||
// meaningful prefix was emitted; the incomplete private handle is dropped.
|
||||
require.Eventually(t, func() bool { return len(bus.finalAnswerContents()) >= 1 }, 2*time.Second, 5*time.Millisecond)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
require.Equal(t, []string{"hello "}, bus.finalAnswerContents())
|
||||
}
|
||||
|
||||
func TestStreamIgnoresDuplicateTerminalAnswer(t *testing.T) {
|
||||
bus := &syncEventBus{}
|
||||
model := &openStreamChat{closeStream: true, chunks: []types.StreamResponse{
|
||||
{ResponseType: types.ResponseTypeAnswer, Content: "hello"},
|
||||
{ResponseType: types.ResponseTypeAnswer, Done: true},
|
||||
// Some providers repeat the same terminal response when their EOF
|
||||
// sentinel is received after finish_reason.
|
||||
{ResponseType: types.ResponseTypeAnswer, Done: true},
|
||||
}}
|
||||
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.SessionID = "sess-duplicate-done"
|
||||
chatManage.EventBus = bus
|
||||
plugin := &PluginChatCompletionStream{modelService: &stubModelService{model: model}}
|
||||
require.Nil(t, plugin.OnEvent(context.Background(), types.CHAT_COMPLETION_STREAM, chatManage, func() *PluginError { return nil }))
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
bus.mu.Lock()
|
||||
defer bus.mu.Unlock()
|
||||
return len(bus.events) == 2
|
||||
}, 2*time.Second, 5*time.Millisecond)
|
||||
|
||||
bus.mu.Lock()
|
||||
defer bus.mu.Unlock()
|
||||
var answerEvents []event.AgentFinalAnswerData
|
||||
for _, evt := range bus.events {
|
||||
if evt.Type == types.EventType(event.EventAgentFinalAnswer) {
|
||||
answerEvents = append(answerEvents, evt.Data.(event.AgentFinalAnswerData))
|
||||
}
|
||||
}
|
||||
require.Equal(t, []event.AgentFinalAnswerData{{Content: "hello"}, {Done: true}}, answerEvents)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// Plugin defines the interface for chat pipeline plugins
|
||||
// Plugins can handle specific events in the chat pipeline
|
||||
type Plugin interface {
|
||||
// OnEvent handles the event with given context and chat management object
|
||||
OnEvent(
|
||||
ctx context.Context,
|
||||
eventType types.EventType,
|
||||
chatManage *types.ChatManage,
|
||||
next func() *PluginError,
|
||||
) *PluginError
|
||||
// ActivationEvents returns the event types this plugin can handle
|
||||
ActivationEvents() []types.EventType
|
||||
}
|
||||
|
||||
// EventManager manages plugins and their event handling
|
||||
type EventManager struct {
|
||||
// Map of event types to registered plugins
|
||||
listeners map[types.EventType][]Plugin
|
||||
// Map of event types to handler functions
|
||||
handlers map[types.EventType]func(context.Context, types.EventType, *types.ChatManage) *PluginError
|
||||
}
|
||||
|
||||
// NewEventManager creates and initializes a new EventManager
|
||||
func NewEventManager() *EventManager {
|
||||
return &EventManager{
|
||||
listeners: make(map[types.EventType][]Plugin),
|
||||
handlers: make(map[types.EventType]func(context.Context, types.EventType, *types.ChatManage) *PluginError),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a plugin to the EventManager and sets up its event handlers
|
||||
func (e *EventManager) Register(plugin Plugin) {
|
||||
if e.listeners == nil {
|
||||
e.listeners = make(map[types.EventType][]Plugin)
|
||||
}
|
||||
if e.handlers == nil {
|
||||
e.handlers = make(map[types.EventType]func(context.Context, types.EventType, *types.ChatManage) *PluginError)
|
||||
}
|
||||
for _, eventType := range plugin.ActivationEvents() {
|
||||
e.listeners[eventType] = append(e.listeners[eventType], plugin)
|
||||
e.handlers[eventType] = e.buildHandler(e.listeners[eventType])
|
||||
}
|
||||
}
|
||||
|
||||
// buildHandler constructs a handler chain for the given plugins
|
||||
func (e *EventManager) buildHandler(plugins []Plugin) func(
|
||||
ctx context.Context, eventType types.EventType, chatManage *types.ChatManage,
|
||||
) *PluginError {
|
||||
next := func(context.Context, types.EventType, *types.ChatManage) *PluginError { return nil }
|
||||
for i := len(plugins) - 1; i >= 0; i-- {
|
||||
current := plugins[i]
|
||||
prevNext := next
|
||||
next = func(ctx context.Context, eventType types.EventType, chatManage *types.ChatManage) *PluginError {
|
||||
return current.OnEvent(ctx, eventType, chatManage, func() *PluginError {
|
||||
return prevNext(ctx, eventType, chatManage)
|
||||
})
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// Trigger invokes the handler for the specified event type
|
||||
func (e *EventManager) Trigger(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage,
|
||||
) *PluginError {
|
||||
if handler, ok := e.handlers[eventType]; ok {
|
||||
return handler(ctx, eventType, chatManage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PluginError represents an error in plugin execution
|
||||
type PluginError struct {
|
||||
Err error // Original error
|
||||
Description string // Human-readable description
|
||||
ErrorType string // Error type identifier
|
||||
}
|
||||
|
||||
// Predefined plugin errors
|
||||
var (
|
||||
ErrSearchNothing = &PluginError{
|
||||
Description: "No relevant content found",
|
||||
ErrorType: "search_nothing",
|
||||
}
|
||||
ErrSearch = &PluginError{
|
||||
Description: "Failed to search knowledge base",
|
||||
ErrorType: "search_failed",
|
||||
}
|
||||
ErrRerank = &PluginError{
|
||||
Description: "Reranking failed",
|
||||
ErrorType: "rerank_failed",
|
||||
}
|
||||
ErrGetRerankModel = &PluginError{
|
||||
Description: "Failed to get rerank model",
|
||||
ErrorType: "get_rerank_model_failed",
|
||||
}
|
||||
ErrGetChatModel = &PluginError{
|
||||
Description: "Failed to get chat model",
|
||||
ErrorType: "get_chat_model_failed",
|
||||
}
|
||||
ErrTemplateParse = &PluginError{
|
||||
Description: "Failed to parse context template",
|
||||
ErrorType: "template_parse_failed",
|
||||
}
|
||||
ErrTemplateExecute = &PluginError{
|
||||
Description: "Failed to generate search content",
|
||||
ErrorType: "template_execution_failed",
|
||||
}
|
||||
ErrModelCall = &PluginError{
|
||||
Description: "Failed to call model",
|
||||
ErrorType: "model_call_failed",
|
||||
}
|
||||
ErrGetHistory = &PluginError{
|
||||
Description: "Failed to get conversation history",
|
||||
ErrorType: "get_history_failed",
|
||||
}
|
||||
)
|
||||
|
||||
// clone creates a copy of the PluginError
|
||||
func (p *PluginError) clone() *PluginError {
|
||||
return &PluginError{
|
||||
Description: p.Description,
|
||||
ErrorType: p.ErrorType,
|
||||
}
|
||||
}
|
||||
|
||||
// WithError attaches an error to the PluginError and returns a new instance
|
||||
func (p *PluginError) WithError(err error) *PluginError {
|
||||
pp := p.clone()
|
||||
pp.Err = err
|
||||
return pp
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// Define a test Plugin implementation
|
||||
type testPlugin struct {
|
||||
name string
|
||||
events []types.EventType
|
||||
shouldError bool
|
||||
errorToReturn *PluginError
|
||||
}
|
||||
|
||||
func (p *testPlugin) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
if p.shouldError {
|
||||
return p.errorToReturn
|
||||
}
|
||||
fmt.Printf("Plugin %s triggered\n", p.name)
|
||||
err := next()
|
||||
fmt.Printf("Plugin %s finished\n", p.name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *testPlugin) ActivationEvents() []types.EventType {
|
||||
return p.events
|
||||
}
|
||||
|
||||
func TestTrigger(t *testing.T) {
|
||||
// Prepare test data
|
||||
ctx := context.Background()
|
||||
chatManage := &types.ChatManage{}
|
||||
testEvent := types.EventType("test_event")
|
||||
|
||||
// Test scenario 1: No plugins registered
|
||||
t.Run("NoPluginsRegistered", func(t *testing.T) {
|
||||
manager := &EventManager{}
|
||||
err := manager.Trigger(ctx, testEvent, chatManage)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 2: Register a normally working plugin
|
||||
t.Run("SinglePluginSuccess", func(t *testing.T) {
|
||||
manager := &EventManager{}
|
||||
plugin := &testPlugin{
|
||||
name: "test_plugin",
|
||||
events: []types.EventType{testEvent},
|
||||
}
|
||||
manager.Register(plugin)
|
||||
|
||||
err := manager.Trigger(ctx, testEvent, chatManage)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 3: Plugin chain call
|
||||
t.Run("PluginChain", func(t *testing.T) {
|
||||
manager := &EventManager{}
|
||||
plugin1 := &testPlugin{
|
||||
name: "plugin1",
|
||||
events: []types.EventType{testEvent},
|
||||
}
|
||||
plugin2 := &testPlugin{
|
||||
name: "plugin2",
|
||||
events: []types.EventType{testEvent},
|
||||
}
|
||||
manager.Register(plugin1)
|
||||
manager.Register(plugin2)
|
||||
|
||||
err := manager.Trigger(ctx, testEvent, chatManage)
|
||||
if err != nil {
|
||||
t.Errorf("Expected nil error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 4: Plugin returns error
|
||||
t.Run("PluginReturnsError", func(t *testing.T) {
|
||||
manager := &EventManager{}
|
||||
expectedErr := &PluginError{Description: "test error"}
|
||||
plugin := &testPlugin{
|
||||
name: "error_plugin",
|
||||
events: []types.EventType{testEvent},
|
||||
shouldError: true,
|
||||
errorToReturn: expectedErr,
|
||||
}
|
||||
manager.Register(plugin)
|
||||
|
||||
err := manager.Trigger(ctx, testEvent, chatManage)
|
||||
if err != expectedErr {
|
||||
t.Errorf("Expected error %v, got %v", expectedErr, err)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 5: A plugin in the chain returns error
|
||||
t.Run("ErrorInPluginChain", func(t *testing.T) {
|
||||
manager := &EventManager{}
|
||||
expectedErr := &PluginError{Description: "test error"}
|
||||
plugin1 := &testPlugin{
|
||||
name: "plugin1",
|
||||
events: []types.EventType{testEvent},
|
||||
}
|
||||
plugin2 := &testPlugin{
|
||||
name: "plugin2",
|
||||
events: []types.EventType{testEvent},
|
||||
shouldError: true,
|
||||
errorToReturn: expectedErr,
|
||||
}
|
||||
manager.Register(plugin1)
|
||||
manager.Register(plugin2)
|
||||
|
||||
err := manager.Trigger(ctx, testEvent, chatManage)
|
||||
if err != expectedErr {
|
||||
t.Errorf("Expected error %v, got %v", expectedErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/common"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
var regThinkTags = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
||||
|
||||
const retrievedImageOutputRequirement = `
|
||||
|
||||
## Retrieved Image Output Requirement
|
||||
The retrieved context for this turn contains Markdown images. Images attached to retrieved passages should be treated as relevant by default.
|
||||
- Unless the user explicitly requests text-only output, or every retrieved image is clearly unrelated to the answer, the final answer MUST include at least one relevant Markdown image copied from the retrieved context.
|
||||
- Copy the complete Markdown image syntax and its URL verbatim. Never invent, shorten, normalize, or replace the URL.
|
||||
- Use ASCII half-width parentheses in image Markdown exactly as . Never use full-width ( or ).
|
||||
- Place each image immediately after the paragraph it supports, rather than collecting images at the end.
|
||||
- When multiple retrieved images support different sections of a multi-section answer, include them in their corresponding sections instead of stopping after the first image.
|
||||
- Before finishing, silently verify that the answer contains a Markdown image whenever this requirement applies.`
|
||||
|
||||
func appendRetrievedImageOutputRequirement(systemPrompt, renderedContexts string) string {
|
||||
if !searchutil.MarkdownImageRegex.MatchString(renderedContexts) {
|
||||
return systemPrompt
|
||||
}
|
||||
return strings.TrimRight(systemPrompt, " \t\r\n") + retrievedImageOutputRequirement
|
||||
}
|
||||
|
||||
// pipelineInfo logs pipeline info level entries.
|
||||
func pipelineInfo(ctx context.Context, stage, action string, fields map[string]interface{}) {
|
||||
common.PipelineInfo(ctx, stage, action, fields)
|
||||
}
|
||||
|
||||
// pipelineWarn logs pipeline warning level entries.
|
||||
func pipelineWarn(ctx context.Context, stage, action string, fields map[string]interface{}) {
|
||||
common.PipelineWarn(ctx, stage, action, fields)
|
||||
}
|
||||
|
||||
// pipelineError logs pipeline error level entries.
|
||||
func pipelineError(ctx context.Context, stage, action string, fields map[string]interface{}) {
|
||||
common.PipelineError(ctx, stage, action, fields)
|
||||
}
|
||||
|
||||
// prepareChatModel shared logic to prepare chat model and options
|
||||
// it gets the chat model and sets up the chat options based on the chat manage.
|
||||
func prepareChatModel(ctx context.Context, modelService interfaces.ModelService,
|
||||
chatManage *types.ChatManage,
|
||||
) (chat.Chat, *chat.ChatOptions, error) {
|
||||
chatModel, err := modelService.GetChatModel(ctx, chatManage.ChatModelID)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to get chat model: %v", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
opt := &chat.ChatOptions{
|
||||
Temperature: chatManage.SummaryConfig.Temperature,
|
||||
TopP: chatManage.SummaryConfig.TopP,
|
||||
Seed: chatManage.SummaryConfig.Seed,
|
||||
MaxTokens: chatManage.SummaryConfig.MaxTokens,
|
||||
MaxCompletionTokens: chatManage.SummaryConfig.MaxCompletionTokens,
|
||||
FrequencyPenalty: chatManage.SummaryConfig.FrequencyPenalty,
|
||||
PresencePenalty: chatManage.SummaryConfig.PresencePenalty,
|
||||
Thinking: chatManage.SummaryConfig.Thinking,
|
||||
PromptCacheKey: chatManage.SessionID,
|
||||
}
|
||||
if opt.Thinking != nil {
|
||||
pipelineInfo(ctx, "Stream", "thinking_option", map[string]interface{}{
|
||||
"enabled": *opt.Thinking,
|
||||
})
|
||||
}
|
||||
|
||||
return chatModel, opt, nil
|
||||
}
|
||||
|
||||
// prepareMessagesWithHistory prepare complete messages including history.
|
||||
// When SystemPromptOverride is set (e.g. by intent-specific prompt logic),
|
||||
// it takes precedence over the default SummaryConfig.Prompt.
|
||||
func prepareMessagesWithHistory(chatManage *types.ChatManage) []chat.Message {
|
||||
base := chatManage.SummaryConfig.Prompt
|
||||
if chatManage.SystemPromptOverride != "" {
|
||||
base = chatManage.SystemPromptOverride
|
||||
}
|
||||
systemPrompt := types.RenderPromptPlaceholders(base, types.PlaceholderValues{
|
||||
"query": chatManage.Query,
|
||||
"language": chatManage.Language,
|
||||
"contexts": chatManage.RenderedContexts,
|
||||
})
|
||||
// Memory goes at the end of the system prompt, after the retrieved-context
|
||||
// placeholders have been rendered, so a remembered sentence can never be
|
||||
// substituted into prompt structure.
|
||||
systemPrompt += chatManage.MemoryPrompt
|
||||
|
||||
chatMessages := []chat.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
}
|
||||
|
||||
chatMessages = AppendHistoryMessages(chatMessages, chatManage.History)
|
||||
|
||||
// Image-output rules are turn-specific. Putting them on the current user
|
||||
// message keeps the system prefix byte-stable so provider prompt caches
|
||||
// still hit on later turns of the same session.
|
||||
userContent := appendRetrievedImageOutputRequirement(chatManage.UserContent, chatManage.RenderedContexts)
|
||||
|
||||
// Add current user message. Only include images when the chat model supports
|
||||
// vision; non-vision models rely on the text description in UserContent.
|
||||
userMsg := chat.Message{Role: "user", Content: userContent}
|
||||
if chatManage.ChatModelSupportsVision && len(chatManage.Images) > 0 {
|
||||
userMsg.Images = chatManage.Images
|
||||
}
|
||||
chatMessages = append(chatMessages, userMsg)
|
||||
|
||||
return chatMessages
|
||||
}
|
||||
|
||||
func withPromptCacheMetadata(
|
||||
ctx context.Context,
|
||||
chatModel chat.Chat,
|
||||
messages []chat.Message,
|
||||
opts *chat.ChatOptions,
|
||||
purpose string,
|
||||
) context.Context {
|
||||
prefixFingerprint := chat.PromptPrefixFingerprint(messages, opts)
|
||||
_ = chatModel // model identity is already captured by the usage sink
|
||||
return types.WithLLMCallMetadata(ctx, purpose, prefixFingerprint)
|
||||
}
|
||||
|
||||
// AppendHistoryMessages appends prior Q&A rounds in chronological order.
|
||||
// History is already filtered and truncated upstream by the load_history plugin.
|
||||
func AppendHistoryMessages(messages []chat.Message, history []*types.History) []chat.Message {
|
||||
for _, history := range history {
|
||||
messages = append(messages, chat.Message{Role: "user", Content: history.Query})
|
||||
messages = append(messages, chat.Message{Role: "assistant", Content: history.Answer})
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// loadAndProcessHistory fetches recent messages, groups them into Q&A pairs,
|
||||
// strips <think> tags from assistant answers, sorts by recency, and limits to maxRounds.
|
||||
// fetchCount controls how many raw messages to fetch (typically maxRounds*2+10).
|
||||
func loadAndProcessHistory(
|
||||
ctx context.Context,
|
||||
messageService interfaces.MessageService,
|
||||
sessionID string,
|
||||
maxRounds int,
|
||||
fetchCount int,
|
||||
) ([]*types.History, error) {
|
||||
history, err := messageService.GetRecentMessagesBySession(ctx, sessionID, fetchCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
historyMap := make(map[string]*types.History)
|
||||
for _, message := range history {
|
||||
h, ok := historyMap[message.RequestID]
|
||||
if !ok {
|
||||
h = &types.History{}
|
||||
}
|
||||
if message.Role == "user" {
|
||||
// RenderedContent is a snapshot of the prompt/context format used by
|
||||
// the original turn. Replaying it would mix legacy <context id="…">
|
||||
// envelopes and old citation instructions into the current protocol.
|
||||
// Historical references are carried separately in KnowledgeReferences
|
||||
// and can be re-merged into this turn's freshly rendered context.
|
||||
h.Query = message.Content
|
||||
h.CreateAt = message.CreatedAt
|
||||
if desc := extractImageCaptions(message.Images); desc != "" {
|
||||
h.Query += "\n\n[用户上传图片内容]\n" + desc
|
||||
}
|
||||
if len(message.Attachments) > 0 {
|
||||
h.Query += message.Attachments.BuildPrompt()
|
||||
}
|
||||
} else {
|
||||
h.Answer = regThinkTags.ReplaceAllString(message.Content, "")
|
||||
h.KnowledgeReferences = message.KnowledgeReferences
|
||||
}
|
||||
historyMap[message.RequestID] = h
|
||||
}
|
||||
|
||||
historyList := make([]*types.History, 0, len(historyMap))
|
||||
for _, h := range historyMap {
|
||||
if h.Answer != "" && h.Query != "" {
|
||||
historyList = append(historyList, h)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(historyList, func(i, j int) bool {
|
||||
return historyList[i].CreateAt.After(historyList[j].CreateAt)
|
||||
})
|
||||
|
||||
if len(historyList) > maxRounds {
|
||||
historyList = historyList[:maxRounds]
|
||||
}
|
||||
|
||||
slices.Reverse(historyList)
|
||||
return historyList, nil
|
||||
}
|
||||
|
||||
// extractImageCaptions concatenates non-empty Caption fields from stored
|
||||
// message images. Used when loading history so that previous turns' image
|
||||
// descriptions are visible to the model.
|
||||
func extractImageCaptions(images types.MessageImages) string {
|
||||
var parts []string
|
||||
for _, img := range images {
|
||||
if img.Caption != "" {
|
||||
parts = append(parts, img.Caption)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Concurrency utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ParallelTask represents a named unit of concurrent work.
|
||||
type ParallelTask struct {
|
||||
Name string
|
||||
Run func() *PluginError
|
||||
}
|
||||
|
||||
// RunParallel executes tasks concurrently.
|
||||
// Returns a map of task name → error for tasks that returned non-nil errors.
|
||||
func RunParallel(tasks ...ParallelTask) map[string]*PluginError {
|
||||
errs := make(map[string]*PluginError)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(len(tasks))
|
||||
for _, task := range tasks {
|
||||
go func(t ParallelTask) {
|
||||
defer wg.Done()
|
||||
if err := t.Run(); err != nil {
|
||||
mu.Lock()
|
||||
errs[t.Name] = err
|
||||
mu.Unlock()
|
||||
}
|
||||
}(task)
|
||||
}
|
||||
wg.Wait()
|
||||
return errs
|
||||
}
|
||||
|
||||
// ParallelMap applies fn to each element of items concurrently (up to
|
||||
// maxWorkers goroutines) and returns results in the same order as items.
|
||||
// If maxWorkers <= 0, concurrency is unbounded (one goroutine per item).
|
||||
func ParallelMap[T, R any](items []T, maxWorkers int, fn func(int, T) R) []R {
|
||||
n := len(items)
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
results := make([]R, n)
|
||||
|
||||
if maxWorkers <= 0 || maxWorkers > n {
|
||||
maxWorkers = n
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, maxWorkers)
|
||||
|
||||
for i, item := range items {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(idx int, it T) {
|
||||
defer func() { <-sem; wg.Done() }()
|
||||
results[idx] = fn(idx, it)
|
||||
}(i, item)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppendRetrievedImageOutputRequirement(t *testing.T) {
|
||||
base := "Answer from retrieved evidence."
|
||||
withImage := appendRetrievedImageOutputRequirement(
|
||||
base,
|
||||
"context\n",
|
||||
)
|
||||
for _, required := range []string{
|
||||
base,
|
||||
"MUST include at least one relevant Markdown image",
|
||||
"Copy the complete Markdown image syntax and its URL verbatim",
|
||||
"ASCII half-width parentheses",
|
||||
"immediately after the paragraph it supports",
|
||||
} {
|
||||
if !strings.Contains(withImage, required) {
|
||||
t.Fatalf("expected %q in dynamic image requirement:\n%s", required, withImage)
|
||||
}
|
||||
}
|
||||
|
||||
withoutImage := appendRetrievedImageOutputRequirement(base, "text-only retrieved context")
|
||||
if withoutImage != base {
|
||||
t.Fatalf("text-only context should not change the user turn: %q", withoutImage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/agent/tools"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
type PluginDataAnalysis struct {
|
||||
modelService interfaces.ModelService
|
||||
knowledgeBaseService interfaces.KnowledgeBaseService
|
||||
knowledgeService interfaces.KnowledgeService
|
||||
fileService interfaces.FileService
|
||||
chunkRepo interfaces.ChunkRepository
|
||||
tenantService interfaces.TenantService
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPluginDataAnalysis(
|
||||
eventManager *EventManager,
|
||||
modelService interfaces.ModelService,
|
||||
knowledgeBaseService interfaces.KnowledgeBaseService,
|
||||
knowledgeService interfaces.KnowledgeService,
|
||||
fileService interfaces.FileService,
|
||||
chunkRepo interfaces.ChunkRepository,
|
||||
tenantService interfaces.TenantService,
|
||||
db *sql.DB,
|
||||
) *PluginDataAnalysis {
|
||||
p := &PluginDataAnalysis{
|
||||
modelService: modelService,
|
||||
knowledgeBaseService: knowledgeBaseService,
|
||||
knowledgeService: knowledgeService,
|
||||
fileService: fileService,
|
||||
chunkRepo: chunkRepo,
|
||||
tenantService: tenantService,
|
||||
db: db,
|
||||
}
|
||||
eventManager.Register(p)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *PluginDataAnalysis) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.DATA_ANALYSIS}
|
||||
}
|
||||
|
||||
func (p *PluginDataAnalysis) OnEvent(
|
||||
ctx context.Context,
|
||||
eventType types.EventType,
|
||||
chatManage *types.ChatManage,
|
||||
next func() *PluginError,
|
||||
) *PluginError {
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
return next()
|
||||
}
|
||||
// 1. Check if there are any CSV/Excel files in MergeResult
|
||||
var dataFiles []*types.SearchResult
|
||||
for _, result := range chatManage.MergeResult {
|
||||
if isDataFile(result.KnowledgeFilename) {
|
||||
dataFiles = append(dataFiles, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out table column and table summary chunks from MergeResult
|
||||
chatManage.MergeResult = filterOutTableChunks(chatManage.MergeResult)
|
||||
|
||||
if len(dataFiles) == 0 {
|
||||
return next()
|
||||
}
|
||||
|
||||
// 2. Ask LLM if data analysis is needed
|
||||
// We only process the first data file for now to avoid complexity
|
||||
targetFile := dataFiles[0]
|
||||
|
||||
// Get Knowledge details to get file path
|
||||
knowledge, err := p.knowledgeService.GetKnowledgeByID(ctx, targetFile.KnowledgeID)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to get knowledge %s: %v", targetFile.KnowledgeID, err)
|
||||
return next()
|
||||
}
|
||||
|
||||
// Initialize DataAnalysisTool
|
||||
tool := tools.NewDataAnalysisTool(p.knowledgeBaseService, p.knowledgeService, p.tenantService, p.fileService, p.db, chatManage.SessionID)
|
||||
defer tool.Cleanup(ctx)
|
||||
|
||||
// Load data into DuckDB
|
||||
schema, err := tool.LoadFromKnowledge(ctx, knowledge)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to get data schema: %v", err)
|
||||
return next()
|
||||
}
|
||||
|
||||
// Ask LLM to generate SQL for data analysis
|
||||
chatModel, err := p.modelService.GetChatModel(ctx, chatManage.ChatModelID)
|
||||
if err != nil {
|
||||
return ErrGetChatModel.WithError(err)
|
||||
}
|
||||
|
||||
// Use utils.GenerateSchema to generate format schema for DataAnalysisInput
|
||||
formatSchema := utils.GenerateSchema[tools.DataAnalysisInput]()
|
||||
|
||||
analysisPrompt := fmt.Sprintf(`
|
||||
User Question: %s
|
||||
Knowledge ID: %s
|
||||
Table Schema: %s
|
||||
|
||||
Determine if the user's question requires data analysis (e.g., statistics, aggregation, filtering) on this table.
|
||||
If YES, generate a DuckDB SQL query to answer the user's question and fill in the knowledge_id and sql fields.
|
||||
If NO, leave the sql field empty.
|
||||
|
||||
Return your response in the specified JSON format.`, chatManage.Query, knowledge.ID, schema.Description())
|
||||
|
||||
modelCtx := types.WithLLMCallMetadata(ctx, "data_analysis_plan", "")
|
||||
response, err := chatModel.Chat(modelCtx, []chat.Message{
|
||||
{Role: "user", Content: analysisPrompt},
|
||||
}, &chat.ChatOptions{
|
||||
Temperature: 0.1,
|
||||
Format: formatSchema,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to generate analysis response: %v", err)
|
||||
return next()
|
||||
}
|
||||
// logger.Debugf(ctx, "Data analysis LLM response: %s", response.Content)
|
||||
|
||||
// Execute SQL using the tool
|
||||
// Initialize DataAnalysisTool
|
||||
toolResult, err := tool.Execute(ctx, json.RawMessage(response.Content))
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to execute SQL: %v", err)
|
||||
return next()
|
||||
}
|
||||
|
||||
// 5. Store result
|
||||
// Create a new SearchResult for the analysis output
|
||||
analysisResult := &types.SearchResult{
|
||||
ID: "analysis_" + knowledge.ID,
|
||||
Content: toolResult.Output,
|
||||
Score: 1.0,
|
||||
MatchType: types.MatchTypeDataAnalysis,
|
||||
KnowledgeID: knowledge.ID,
|
||||
KnowledgeTitle: knowledge.Title,
|
||||
KnowledgeFilename: knowledge.FileName,
|
||||
KnowledgeDescription: knowledge.Description,
|
||||
}
|
||||
|
||||
chatManage.MergeResult = append(chatManage.MergeResult, analysisResult)
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
func isDataFile(filename string) bool {
|
||||
lower := strings.ToLower(filename)
|
||||
return strings.HasSuffix(lower, ".csv") || strings.HasSuffix(lower, ".xlsx") || strings.HasSuffix(lower, ".xls")
|
||||
}
|
||||
|
||||
// filterOutTableChunks filters out table column and table summary chunks from search results
|
||||
func filterOutTableChunks(results []*types.SearchResult) []*types.SearchResult {
|
||||
filtered := make([]*types.SearchResult, 0, len(results))
|
||||
filterList := []string{string(types.ChunkTypeTableColumn), string(types.ChunkTypeTableSummary)}
|
||||
for _, result := range results {
|
||||
if slices.Contains(filterList, result.ChunkType) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, result)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
var graphStorageReachInstructionPattern = regexp.MustCompile(`^(?:请\s*)?(?:放\s*置\s*于|置\s*于|放\s*在).+不\s*易\s*触\s*及(?:\s*之)?\s*处$`)
|
||||
|
||||
// This bounded coverage check recognizes explicit placement instructions only.
|
||||
// Unclear OCR stays verbatim; detecting a missing rule must never create an edge.
|
||||
func validateGraphStorageCoverage(graph *types.GraphData, content string) error {
|
||||
for _, clause := range strings.FieldsFunc(content, func(r rune) bool {
|
||||
return strings.ContainsRune(",,。!?!?;;::\n\r", r)
|
||||
}) {
|
||||
clause = strings.TrimSpace(clause)
|
||||
if heading := graphSectionHeadingPattern.FindStringIndex(clause); heading != nil && heading[0] == 0 {
|
||||
clause = strings.TrimSpace(clause[heading[1]:])
|
||||
}
|
||||
if !graphStorageReachInstructionPattern.MatchString(clause) {
|
||||
continue
|
||||
}
|
||||
covered := false
|
||||
for _, relation := range graph.Relation {
|
||||
if relation != nil && relation.Type == "约束" &&
|
||||
strings.Contains(relation.Node2, clause) &&
|
||||
strings.Contains(relation.Evidence, clause) &&
|
||||
strings.Contains(content, relation.Evidence) {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !covered {
|
||||
return fmt.Errorf("%w: explicit storage restriction is not covered by a complete source-grounded constraint: %q; preserve the original clause verbatim without repairing OCR if its relationship is supported, otherwise leave this source for review; never invent an edge to satisfy coverage", ErrGraphNeedsReview, clause)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func graphCoverageResponse(t *testing.T, graph *types.GraphData) *types.ChatResponse {
|
||||
t.Helper()
|
||||
items := make([]map[string]any, 0, len(graph.Node)+len(graph.Relation))
|
||||
for _, node := range graph.Node {
|
||||
items = append(items, map[string]any{"entity": node.Name, "entity_attributes": node.Attributes})
|
||||
}
|
||||
for _, relation := range graph.Relation {
|
||||
items = append(items, map[string]any{"entity1": relation.Node1, "entity2": relation.Node2, "relation": relation.Type, "evidence": relation.Evidence})
|
||||
}
|
||||
raw, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &types.ChatResponse{Content: string(raw), FinishReason: "stop"}
|
||||
}
|
||||
|
||||
func TestGraphStorageCoverageSavedProbeRequiresReview(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/graph_storage_coverage_add45b26.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture struct {
|
||||
SourceSHA256 string `json:"source_sha256"`
|
||||
Source string `json:"source_content"`
|
||||
Graph *types.GraphData `json:"graph"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256([]byte(fixture.Source))
|
||||
if hex.EncodeToString(digest[:]) != fixture.SourceSHA256 || len(fixture.Graph.Relation) != 10 {
|
||||
t.Fatal("saved probe source or returned relations changed")
|
||||
}
|
||||
model := &extractionResponseChat{response: graphCoverageResponse(t, fixture.Graph)}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), fixture.Source)
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), "置于丿 L 童不易触及处") || model.calls != 2 {
|
||||
t.Fatalf("saved omission must require review after one correction: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
if !strings.Contains(model.messages[len(model.messages)-1].Content, "置于丿 L 童不易触及处") {
|
||||
t.Fatal("correction lost the original OCR clause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphStorageCoverageRequiresCompleteConstraint(t *testing.T) {
|
||||
const clause = "置于丿 L 童不易触及处"
|
||||
const source = "本品不得口服," + clause + "。"
|
||||
for _, tc := range []struct {
|
||||
name, target, label, evidence string
|
||||
attribute bool
|
||||
}{
|
||||
{name: "omitted relation"},
|
||||
{name: "attribute alone", attribute: true},
|
||||
{name: "normalized OCR target", target: "置于儿童不易触及处", label: "约束", evidence: clause},
|
||||
{name: "restriction removed from target", target: "置于丿 L 童触及处", label: "约束", evidence: clause},
|
||||
{name: "evidence alone", target: "储存要求", label: "约束", evidence: clause},
|
||||
{name: "unrelated evidence", target: clause, label: "约束", evidence: "本品不得口服"},
|
||||
{name: "wrong relationship", target: clause, label: "关联", evidence: clause},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
input := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "本品", Node2: "口服", Type: "禁用于", Evidence: "本品不得口服"}}}
|
||||
if tc.target != "" {
|
||||
input.Relation = append(input.Relation, &types.GraphRelation{Node1: "本品", Node2: tc.target, Type: tc.label, Evidence: tc.evidence})
|
||||
}
|
||||
if tc.attribute {
|
||||
input.Node = []*types.GraphNode{{Name: "本品", Attributes: []string{clause}}}
|
||||
}
|
||||
model := &extractionResponseChat{response: graphCoverageResponse(t, input)}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) || model.calls != 2 {
|
||||
t.Fatalf("missing constraint passed: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphStorageCoverageCorrectionPreservesOCR(t *testing.T) {
|
||||
const clause = "置于丿 L 童不易触及处"
|
||||
const source = "本品不得口服," + clause + "。"
|
||||
input := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "本品", Node2: "口服", Type: "禁用于", Evidence: "本品不得口服"}}}
|
||||
incomplete := graphCoverageResponse(t, input)
|
||||
input.Relation = append(input.Relation, &types.GraphRelation{Node1: "本品", Node2: clause, Type: "约束", Evidence: clause})
|
||||
model := &extractionResponseChat{responses: []*types.ChatResponse{incomplete, graphCoverageResponse(t, input)}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 2 || model.calls != 2 {
|
||||
t.Fatalf("source-backed correction failed: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
if graph.Relation[1].Node2 != clause || graph.Relation[1].Evidence != clause {
|
||||
t.Fatalf("OCR was changed: %+v", graph.Relation[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphStorageCoverageScope(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
source string
|
||||
wantReview bool
|
||||
}{
|
||||
{"请放置于儿童不易触及之处。", true},
|
||||
{"〔 储存方法 〗 置于丿 L 童不易触及处。", true},
|
||||
{"放在儿童不易触及处。", true},
|
||||
{"教材介绍储存要求。", false},
|
||||
{"作者讨论“置于儿童不易触及处”这句话。", false},
|
||||
{"他认为置于儿童不易触及处能够保证安全。", false},
|
||||
} {
|
||||
err := validateGraphStorageCoverage(&types.GraphData{}, tc.source)
|
||||
if errors.Is(err, ErrGraphNeedsReview) != tc.wantReview {
|
||||
t.Errorf("source=%q, error=%v, want review=%v", tc.source, err, tc.wantReview)
|
||||
}
|
||||
}
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: `[{"entity":"储存"}]`, FinishReason: "stop"}}
|
||||
extractor := NewExtractor(model, &types.PromptTemplateStructured{Description: "Extract query entities"})
|
||||
graph, err := extractor.Extract(context.Background(), "置于丿 L 童不易触及处")
|
||||
if err != nil || graph == nil || len(graph.Node) != 1 || model.calls != 1 {
|
||||
t.Fatalf("query extraction should not require persisted-graph coverage: graph=%+v, err=%v", graph, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
var graphPriceLabelPattern = regexp.MustCompile(`(?:终端)?零售(?:价格|价)|批发(?:价格|价)|促销(?:价格|价)|优惠(?:价格|价)|活动(?:价格|价)|会员(?:价格|价)|建议零售价|销售价格|售价|单价|原价|现价|价格`)
|
||||
var graphPriceAmountPattern = regexp.MustCompile(`^(人民币|[¥¥$])?([0-9]+(?:\.[0-9]+)?)(美元|人民币|元|角|分|盒|箱|瓶|套|支|粒|片|件|袋|kg|mg|ml|g)?(/(?:[0-9一二两俩三四五六七八九十百千]+)?(?:盒|箱|瓶|套|支|粒|片|件|袋|kg|mg|ml|g))?`)
|
||||
var graphPriceHeaderUnitPattern = regexp.MustCompile(`^[((](元|美元|元/(?:盒|箱|瓶|套|支|件))[))]$`)
|
||||
|
||||
type graphPriceValue struct {
|
||||
number string
|
||||
unit string
|
||||
}
|
||||
|
||||
type graphPriceClaim struct {
|
||||
label string
|
||||
values []graphPriceValue
|
||||
prefix string
|
||||
hasQuantity bool
|
||||
}
|
||||
|
||||
func graphPriceValues(value, headerUnit string) []graphPriceValue {
|
||||
value = strings.TrimLeft(compactGraphText(value), "::为是")
|
||||
var values []graphPriceValue
|
||||
for value != "" {
|
||||
match := graphPriceAmountPattern.FindStringSubmatch(value)
|
||||
if match == nil {
|
||||
// Do not accept a parsed prefix while silently ignoring a changed
|
||||
// package quantity or another amount later in the same price claim.
|
||||
if strings.HasPrefix(value, "/") || graphQuantityPattern.MatchString(value) {
|
||||
return nil
|
||||
}
|
||||
break
|
||||
}
|
||||
unit := match[3] + match[4]
|
||||
if match[1] != "" {
|
||||
currency := "元"
|
||||
if match[1] == "$" {
|
||||
currency = "美元"
|
||||
}
|
||||
if unit == "" || strings.HasPrefix(unit, "/") {
|
||||
unit = currency + unit
|
||||
}
|
||||
}
|
||||
if unit == "" {
|
||||
unit = headerUnit
|
||||
}
|
||||
values = append(values, graphPriceValue{number: match[2], unit: unit})
|
||||
value = strings.TrimLeft(value[len(match[0]):], ",,、")
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func graphPriceClaims(value string) []graphPriceClaim {
|
||||
value = compactGraphText(value)
|
||||
labels := graphPriceLabelPattern.FindAllStringIndex(value, -1)
|
||||
var claims []graphPriceClaim
|
||||
for i, span := range labels {
|
||||
end := len(value)
|
||||
if i+1 < len(labels) {
|
||||
end = labels[i+1][0]
|
||||
}
|
||||
prefix := value[:span[0]]
|
||||
if index := strings.LastIndexAny(prefix, ",,;;。|::"); index >= 0 {
|
||||
prefix = strings.TrimLeft(prefix[index:], ",,;;。|::")
|
||||
}
|
||||
claims = append(claims, graphPriceClaim{
|
||||
label: graphPriceLabel(value[span[0]:span[1]]),
|
||||
values: graphPriceValues(value[span[1]:end], ""),
|
||||
prefix: prefix,
|
||||
hasQuantity: graphQuantityPattern.MatchString(value[span[1]:end]),
|
||||
})
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func graphPriceLabel(value string) string {
|
||||
return strings.TrimPrefix(strings.ReplaceAll(value, "价格", "价"), "终端")
|
||||
}
|
||||
|
||||
func graphPriceValuesMatch(want, available []graphPriceValue) bool {
|
||||
if len(want) == 0 || len(want) > len(available) {
|
||||
return false
|
||||
}
|
||||
for start := 0; start+len(want) <= len(available); start++ {
|
||||
matches := true
|
||||
for i, value := range want {
|
||||
actual := available[start+i]
|
||||
if value.number != actual.number || (value.unit != "" && value.unit != actual.unit) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func graphPricePrefixMatches(prefix, name string, inherited bool) bool {
|
||||
prefix = strings.TrimSuffix(compactGraphText(prefix), "的")
|
||||
if prefix == compactGraphText(name) {
|
||||
return true
|
||||
}
|
||||
return inherited && (prefix == "" || prefix == "本品" || prefix == "该产品" || prefix == "其")
|
||||
}
|
||||
|
||||
func graphPriceSentenceSupports(sentence, name string, inherited bool, want graphPriceClaim) bool {
|
||||
for _, claim := range graphPriceClaims(sentence) {
|
||||
if !graphPricePrefixMatches(claim.prefix, name, inherited) {
|
||||
inherited = false
|
||||
continue
|
||||
}
|
||||
inherited = true
|
||||
if claim.label == want.label && graphPriceValuesMatch(want.values, claim.values) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func graphPriceTableCells(line string) []string {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "|") {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(strings.TrimSuffix(strings.TrimPrefix(line, "|"), "|"), "|")
|
||||
}
|
||||
|
||||
func graphPriceProductHeader(value string) bool {
|
||||
switch compactGraphText(value) {
|
||||
case "产品名称", "商品名称", "品名", "产品":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func graphPriceTableRowMatches(cells, headers []string, name string) bool {
|
||||
name = compactGraphText(name)
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
if len(headers) == 0 {
|
||||
for _, cell := range cells {
|
||||
if compactGraphText(cell) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if len(cells) > len(headers) {
|
||||
return false
|
||||
}
|
||||
productColumn, brandColumn := -1, -1
|
||||
for i, header := range headers {
|
||||
if graphPriceProductHeader(header) {
|
||||
if productColumn >= 0 {
|
||||
return false
|
||||
}
|
||||
productColumn = i
|
||||
}
|
||||
if header = compactGraphText(header); header == "品牌" || header == "品牌名称" {
|
||||
if brandColumn >= 0 {
|
||||
return false
|
||||
}
|
||||
brandColumn = i
|
||||
}
|
||||
}
|
||||
if productColumn < 0 || productColumn >= len(cells) {
|
||||
return false
|
||||
}
|
||||
product := compactGraphText(cells[productColumn])
|
||||
if product == "" {
|
||||
return false
|
||||
}
|
||||
if product == name {
|
||||
return true
|
||||
}
|
||||
if brandColumn < 0 || brandColumn >= len(cells) {
|
||||
return false
|
||||
}
|
||||
brand := compactGraphText(cells[brandColumn])
|
||||
return brand != "" && brand+product == name
|
||||
}
|
||||
|
||||
func graphPriceAttributeSupported(content, name string, want graphPriceClaim) bool {
|
||||
var headers []string
|
||||
headingMatches := false
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if cells := graphPriceTableCells(line); cells != nil {
|
||||
headingMatches = false
|
||||
isHeader := false
|
||||
for _, cell := range cells {
|
||||
if graphPriceProductHeader(cell) {
|
||||
isHeader = true
|
||||
}
|
||||
}
|
||||
if isHeader {
|
||||
headers = cells
|
||||
continue
|
||||
}
|
||||
// A short row may end midway through its final cell. Only closed
|
||||
// cells have a known boundary; never validate a truncated price.
|
||||
if len(cells) < len(headers) && !strings.HasSuffix(strings.TrimSpace(line), "|") {
|
||||
cells = cells[:len(cells)-1]
|
||||
}
|
||||
if !graphPriceTableRowMatches(cells, headers, name) {
|
||||
continue
|
||||
}
|
||||
for i, cell := range cells {
|
||||
if graphPriceSentenceSupports(cell, name, true, want) {
|
||||
return true
|
||||
}
|
||||
// A chunk may end before the row's final cells; only existing
|
||||
// leading cells can inherit their corresponding explicit header.
|
||||
if i >= len(headers) {
|
||||
continue
|
||||
}
|
||||
header := compactGraphText(headers[i])
|
||||
span := graphPriceLabelPattern.FindStringIndex(header)
|
||||
if span == nil || span[0] != 0 || graphPriceLabel(header[:span[1]]) != want.label {
|
||||
continue
|
||||
}
|
||||
unit := ""
|
||||
if suffix := header[span[1]:]; suffix != "" {
|
||||
match := graphPriceHeaderUnitPattern.FindStringSubmatch(suffix)
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
unit = match[1]
|
||||
}
|
||||
if graphPriceValuesMatch(want.values, graphPriceValues(cell, unit)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
headers = nil
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") || graphProductNameMarkerPattern.MatchString(trimmed) {
|
||||
heading := strings.TrimLeft(trimmed, "# ")
|
||||
heading = strings.TrimPrefix(graphProductIdentity(heading), "产品名称")
|
||||
headingMatches = heading == graphProductIdentity(name)
|
||||
continue
|
||||
}
|
||||
for _, sentence := range strings.FieldsFunc(line, func(r rune) bool { return strings.ContainsRune("。!?!?;;", r) }) {
|
||||
if graphPriceSentenceSupports(sentence, name, headingMatches, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateGraphPriceAttributes(graph *types.GraphData, content string) error {
|
||||
for i, node := range graph.Node {
|
||||
for j, attribute := range node.Attributes {
|
||||
for _, claim := range graphPriceClaims(attribute) {
|
||||
if !claim.hasQuantity {
|
||||
continue
|
||||
}
|
||||
if !graphPriceAttributeSupported(content, node.Name, claim) {
|
||||
return fmt.Errorf("%w: entity %d attribute %d has an unsupported price label/value (%s); require an explicit label and matching amount/unit in this product's sentence or table column, never infer unheaded prices", ErrGraphNeedsReview, i+1, j+1, claim.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// Replay private evidence without checking source material into the repository
|
||||
// or invoking the model. The original failed response must remain rejected.
|
||||
func TestGraphPricePrivateReplayKeepsProductScopeRejection(t *testing.T) {
|
||||
path := os.Getenv("GRAPH_PRICE_REPLAY_REPORT")
|
||||
if path == "" {
|
||||
t.Skip("private replay report not configured")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal("cannot read private replay report")
|
||||
}
|
||||
var report struct {
|
||||
ChunkID string `json:"chunk_id"`
|
||||
Source string `json:"source_content"`
|
||||
SourceSHA256 string `json:"source_sha256"`
|
||||
RejectedContent string `json:"rejected_model_content"`
|
||||
ProductionWrite bool `json:"production_write"`
|
||||
Passed bool `json:"passed"`
|
||||
}
|
||||
if json.Unmarshal(data, &report) != nil {
|
||||
t.Fatal("invalid private replay report")
|
||||
}
|
||||
hash := sha256.Sum256([]byte(report.Source))
|
||||
if report.ChunkID != "e40b74eb-0ac7-4ff6-a107-00ce5adfe610" ||
|
||||
report.SourceSHA256 != "4c4d84679328fc6dd3c17add7a91d13669c2d0cc2bc507098ed4fcc1c0dd84b2" ||
|
||||
hex.EncodeToString(hash[:]) != report.SourceSHA256 || report.RejectedContent == "" ||
|
||||
report.ProductionWrite || report.Passed {
|
||||
t.Fatal("private replay evidence does not match the reviewed failed candidate")
|
||||
}
|
||||
graph, err := NewFormater().parseGraph(context.Background(), report.RejectedContent, &report.Source)
|
||||
if graph != nil || err == nil ||
|
||||
!strings.Contains(err.Error(), "assigns an earlier unnamed product fragment to a later named product") {
|
||||
t.Fatal("replayed cross-product relationships were not rejected")
|
||||
}
|
||||
if strings.Contains(err.Error(), "unsupported price label/value") {
|
||||
t.Fatal("explicit price in the reviewed table is still falsely rejected")
|
||||
}
|
||||
var sections struct {
|
||||
Sections []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"source_sections"`
|
||||
}
|
||||
if json.Unmarshal([]byte(graphProductSourcePrompt(report.Source)), §ions) != nil {
|
||||
t.Fatal("reviewed multi-product source was not sectioned")
|
||||
}
|
||||
var reconstructed strings.Builder
|
||||
for _, section := range sections.Sections {
|
||||
reconstructed.WriteString(section.Text)
|
||||
}
|
||||
if reconstructed.String() != report.Source || len(sections.Sections) != 3 {
|
||||
t.Fatal("reviewed source sections changed original source or table boundaries")
|
||||
}
|
||||
// This is an offline feasibility check, not a production repair: only the
|
||||
// two reviewed mistaken subjects change. Every quotation and price stays.
|
||||
var items []map[string]any
|
||||
if json.Unmarshal([]byte(NewFormater().extractContent(context.Background(), report.RejectedContent)), &items) != nil {
|
||||
t.Fatal("invalid reviewed response")
|
||||
}
|
||||
changed := 0
|
||||
for _, item := range items {
|
||||
from, _ := item["entity1"].(string)
|
||||
quote, _ := item["evidence"].(string)
|
||||
if from != "" && quote != "" && graphBorrowsLaterProduct(report.Source, &types.GraphRelation{Node1: from, Evidence: quote}) {
|
||||
item["entity1"] = "本品"
|
||||
changed++
|
||||
}
|
||||
}
|
||||
if changed != 2 {
|
||||
t.Fatal("reviewed scope rejection count changed")
|
||||
}
|
||||
repaired, _ := json.Marshal(items)
|
||||
if graph, err := NewFormater().parseGraph(context.Background(), string(repaired), &report.Source); err != nil || graph == nil {
|
||||
t.Fatal("scope-only offline correction did not pass unchanged source/price validation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestGraphPriceAttributesRequireExplicitProductEvidence(t *testing.T) {
|
||||
const unheaded = "| 私觅玉丹 | 紧紧丹 | 0.5g/7粒 | 39元/盒 | 34元/一盒 99盒/箱 | 199元/一盒 399元/三盒 |\n\n| 私觅玉丹 | 水光胶原棒 | 3g/6支 | 39元/盒 | 34元/盒 99盒/箱 | 199元/一盒 399元/三盒 |"
|
||||
const table = "| 产品名称 | 零售价 | 批发价 |\n| --- | --- | --- |\n| 甲产品 | 39元/盒 | 34元/盒 |\n| 乙产品 | 59元/盒 | 54元/盒 |"
|
||||
for _, tc := range []struct {
|
||||
name, source, product, attribute string
|
||||
valid bool
|
||||
}{
|
||||
{name: "probe cannot infer retail column", source: unheaded, product: "紧紧丹", attribute: "零售价:39元/盒"},
|
||||
{name: "probe cannot infer wholesale column", source: unheaded, product: "紧紧丹", attribute: "批发价:34元/一盒,99盒/箱"},
|
||||
{name: "probe cannot infer promotion column", source: unheaded, product: "水光胶原棒", attribute: "促销价:199元/一盒,399元/三盒"},
|
||||
{name: "raw source amount does not invent price category", source: unheaded, product: "紧紧丹", attribute: "39元/盒", valid: true},
|
||||
{name: "non price summaries remain supported", source: "甲产品采用简便包装,每盒7粒。", product: "甲产品", attribute: "包装便于使用;规格:7粒/盒", valid: true},
|
||||
{name: "unresolved price without amount is not a price assertion", source: "甲产品每盒7粒,价格待确认。", product: "甲产品", attribute: "规格:7粒/盒;价格待确认", valid: true},
|
||||
{name: "explicit sentence", source: "甲产品的零售价为39元/盒。", product: "甲产品", attribute: "零售价:39元/盒", valid: true},
|
||||
{name: "sentence punctuation and spaces may differ", source: "甲产品零售价格:39 元 / 盒,批发价:34元/盒。", product: "甲产品", attribute: "零售价39元/盒;批发价格34元/盒", valid: true},
|
||||
{name: "label cannot borrow other labelled value", source: "甲产品零售价39元/盒,批发价34元/盒。", product: "甲产品", attribute: "零售价:34元/盒"},
|
||||
{name: "cannot borrow another product sentence", source: "甲产品零售价39元/盒。乙产品零售价59元/盒。", product: "甲产品", attribute: "零售价:59元/盒"},
|
||||
{name: "cannot borrow another product in same sentence", source: "甲产品零售价39元/盒,乙产品零售价59元/盒。", product: "甲产品", attribute: "零售价:59元/盒"},
|
||||
{name: "cannot match product name substring", source: "甲产品加强版零售价39元/盒。", product: "甲产品", attribute: "零售价:39元/盒"},
|
||||
{name: "different packaging unit", source: "甲产品零售价39元/箱。", product: "甲产品", attribute: "零售价:39元/盒"},
|
||||
{name: "different amount cannot match numeric substring", source: "甲产品零售价399元/盒。", product: "甲产品", attribute: "零售价:39元/盒"},
|
||||
{name: "promotion pack amounts preserved", source: "甲产品促销价199元/一盒,399元/三盒。", product: "甲产品", attribute: "促销价:199元/一盒,399元/三盒", valid: true},
|
||||
{name: "promotion pack counts cannot change", source: "甲产品促销价199元/一盒,399元/三盒。", product: "甲产品", attribute: "促销价:399元/一盒"},
|
||||
{name: "unparsed packaging count cannot be ignored", source: "甲产品批发价34元/盒,99盒/箱。", product: "甲产品", attribute: "批发价34元/盒(199盒/箱)"},
|
||||
{name: "table row and header", source: table, product: "甲产品", attribute: "零售价:39元/盒;批发价:34元/盒", valid: true},
|
||||
{name: "table cannot swap columns", source: table, product: "甲产品", attribute: "零售价:34元/盒"},
|
||||
{name: "table cannot borrow other row", source: table, product: "甲产品", attribute: "零售价:59元/盒"},
|
||||
{name: "table cannot use shared brand as product", source: "| 品牌 | 产品名称 | 零售价 |\n| --- | --- | --- |\n| 甲 | 甲精华 | 39元/盒 |", product: "甲", attribute: "零售价:39元/盒"},
|
||||
{name: "table header supplies explicit unit", source: "| 产品名称 | 零售价(元/盒) |\n| --- | --- |\n| 甲产品 | 39 |", product: "甲产品", attribute: "零售价:39元/盒", valid: true},
|
||||
{name: "table missing unit cannot invent yuan", source: "| 产品名称 | 零售价 |\n| --- | --- |\n| 甲产品 | 39 |", product: "甲产品", attribute: "零售价:39元/盒"},
|
||||
{name: "header cannot bleed into another table", source: table + "\n\n| 丙产品 | 69元/盒 | 64元/盒 |", product: "丙产品", attribute: "零售价:69元/盒"},
|
||||
{name: "headerless row with explicit cell label", source: "| 甲产品 | 零售价:39元/盒 |", product: "甲产品", attribute: "零售价:39元/盒", valid: true},
|
||||
{name: "named section", source: "〔产品名称〕甲产品\n零售价:39元/盒。\n\n〔产品名称〕乙产品\n零售价:59元/盒。", product: "甲产品", attribute: "零售价:39元/盒", valid: true},
|
||||
{name: "sections cannot borrow price", source: "〔产品名称〕甲产品\n零售价:39元/盒。\n\n〔产品名称〕乙产品\n零售价:59元/盒。", product: "甲产品", attribute: "零售价:59元/盒"},
|
||||
{name: "numeric unsupported phrasing cannot bypass validation", source: "甲产品39元/盒。", product: "甲产品", attribute: "零售价约39元/盒"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
graph := &types.GraphData{Node: []*types.GraphNode{{Name: tc.product, Attributes: []string{tc.attribute}}}}
|
||||
err := validateGraphPriceAttributes(graph, tc.source)
|
||||
if tc.valid && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tc.valid && !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatalf("ambiguous price must require review: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(graph.Node[0].Attributes, []string{tc.attribute}) {
|
||||
t.Fatal("validation silently rewrote or removed an attribute")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphPriceTableFormattingPreservesProductAndPackageScope(t *testing.T) {
|
||||
const headers = "| 品牌名称 | 产品名称 | 图片 | 规格 | 终端零售价 | 产品说明 | 其他 |\n| --- | --- | --- | --- | --- | --- | --- |\n"
|
||||
const truncatedRow = "| 甲品牌 | 甲精华 | | 30ml/1瓶 | 139元/一瓶 239元/俩瓶 | 原文说明"
|
||||
const fullRow = truncatedRow + " | 补充说明 |"
|
||||
const otherRow = "| 乙品牌 | 乙精华 | | 30ml/1瓶 | 159元/一瓶 259元/俩瓶 | 原文说明 | 补充说明 |"
|
||||
for _, tc := range []struct {
|
||||
name, source, product, attribute string
|
||||
valid bool
|
||||
}{
|
||||
{name: "complete row with same-row brand and product", source: headers + fullRow, product: "甲品牌 甲精华", attribute: "终端零售价:139元/一瓶 239元/俩瓶", valid: true},
|
||||
{name: "trailing pipe is optional", source: headers + strings.TrimSuffix(fullRow, "|"), product: "甲精华", attribute: "终端零售价:239元/俩瓶", valid: true},
|
||||
{name: "header trailing pipe is optional", source: strings.ReplaceAll(headers, "|\n", "\n") + fullRow, product: "甲精华", attribute: "终端零售价:239元/俩瓶", valid: true},
|
||||
{name: "available columns in truncated row", source: headers + truncatedRow, product: "甲品牌甲精华", attribute: "终端零售价:139元/一瓶 239元/俩瓶", valid: true},
|
||||
{name: "available columns with truncated row delimiter", source: headers + truncatedRow + " |", product: "甲精华", attribute: "零售价:239元/俩瓶", valid: true},
|
||||
{name: "terminal retail sentence", source: "甲精华终端零售价格139元/一瓶,239元/俩瓶。", product: "甲精华", attribute: "终端零售价:139元/一瓶,239元/俩瓶", valid: true},
|
||||
{name: "terminal retail is explicit retail category", source: headers + fullRow, product: "甲精华", attribute: "零售价:239元/俩瓶", valid: true},
|
||||
{name: "brand alone cannot own the price", source: headers + fullRow, product: "甲品牌", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "product substring cannot own the price", source: headers + fullRow, product: "甲精", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "brand substring cannot own the price", source: headers + fullRow, product: "甲牌甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "brand and product must come from same row", source: headers + fullRow + "\n" + otherRow, product: "乙品牌甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "same product under another brand cannot supply price", source: headers + fullRow + "\n" + strings.Replace(otherRow, "乙精华", "甲精华", 1), product: "甲品牌甲精华", attribute: "终端零售价:259元/俩瓶"},
|
||||
{name: "another row cannot supply price", source: headers + truncatedRow + "\n" + otherRow, product: "甲品牌甲精华", attribute: "终端零售价:259元/俩瓶"},
|
||||
{name: "two bottle price cannot become single bottle", source: headers + truncatedRow, product: "甲精华", attribute: "终端零售价:239元/一瓶"},
|
||||
{name: "two bottle quantity cannot be dropped", source: headers + truncatedRow, product: "甲精华", attribute: "终端零售价:239元/瓶"},
|
||||
{name: "two bottle unit cannot become box", source: headers + truncatedRow, product: "甲精华", attribute: "终端零售价:239元/俩盒"},
|
||||
{name: "two bottle token cannot be silently changed", source: headers + truncatedRow, product: "甲精华", attribute: "终端零售价:239元/两瓶"},
|
||||
{name: "unclosed price in a short row cannot be accepted", source: headers + "| 甲品牌 | 甲精华 | | 30ml/1瓶 | 239元", product: "甲精华", attribute: "终端零售价:239元"},
|
||||
{name: "truncated package cannot be ignored", source: "甲精华终端零售价239元/俩", product: "甲精华", attribute: "终端零售价:239元"},
|
||||
{name: "unknown package count cannot be ignored", source: "甲精华终端零售价239元/仨瓶", product: "甲精华", attribute: "终端零售价:239元"},
|
||||
{name: "terminal retail cannot become suggested retail", source: headers + truncatedRow, product: "甲精华", attribute: "建议零售价:239元/俩瓶"},
|
||||
{name: "terminal retail cannot become wholesale", source: headers + truncatedRow, product: "甲精华", attribute: "批发价:239元/俩瓶"},
|
||||
{name: "unheaded amounts still cannot infer retail", source: truncatedRow, product: "甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "missing price column cannot borrow other cells", source: headers + "| 甲品牌 | 甲精华 | 239元/俩瓶", product: "甲品牌甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "missing product column cannot borrow heading", source: "| 品牌名称 | 终端零售价 | 产品名称 |\n| --- | --- | --- |\n| 甲品牌 | 239元/俩瓶", product: "甲品牌甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "blank product cannot match brand", source: headers + strings.Replace(fullRow, "甲精华", "", 1), product: "甲品牌", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "overlong row is ambiguous", source: headers + fullRow + " 多余单元格 |", product: "甲品牌甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
{name: "header cannot survive intervening prose", source: headers + "表格结束\n" + truncatedRow, product: "甲精华", attribute: "终端零售价:239元/俩瓶"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
graph := &types.GraphData{Node: []*types.GraphNode{{Name: tc.product, Attributes: []string{tc.attribute}}}}
|
||||
err := validateGraphPriceAttributes(graph, tc.source)
|
||||
if tc.valid && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tc.valid && !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatalf("unsupported product, column or package was accepted: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(graph.Node[0].Attributes, []string{tc.attribute}) {
|
||||
t.Fatal("validation rewrote the source-supported attribute")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func graphPriceTestResponse(t *testing.T, attribute string) *types.ChatResponse {
|
||||
t.Helper()
|
||||
items := []map[string]interface{}{
|
||||
{"entity": "甲产品", "entity_attributes": []string{attribute}},
|
||||
{"entity1": "甲产品", "entity2": "成分甲", "relation": "包含", "evidence": "甲产品包含成分甲"},
|
||||
}
|
||||
raw, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &types.ChatResponse{Content: string(raw), FinishReason: "stop"}
|
||||
}
|
||||
|
||||
func TestGraphPriceAmbiguityRequiresReviewAfterBoundedCorrection(t *testing.T) {
|
||||
const source = "甲产品包含成分甲。\n| 甲产品 | 39元/盒 | 34元/盒 |"
|
||||
model := &extractionResponseChat{response: graphPriceTestResponse(t, "零售价:39元/盒")}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) || model.calls != 2 {
|
||||
t.Fatalf("ambiguous attribute completed or retried indefinitely: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
if !strings.Contains(model.messages[len(model.messages)-1].Content, "unsupported price label/value") {
|
||||
t.Fatal("bounded correction did not receive the price attribution failure")
|
||||
}
|
||||
if !strings.Contains(model.messages[0].Content, "无表头的金额列不能推断") {
|
||||
t.Fatal("graph instruction must cover headerless sources without product markers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphPriceCorrectionCanRestoreExplicitPrice(t *testing.T) {
|
||||
const source = "甲产品包含成分甲。甲产品零售价39元/盒,批发价34元/盒。"
|
||||
model := &extractionResponseChat{responses: []*types.ChatResponse{
|
||||
graphPriceTestResponse(t, "零售价:34元/盒"),
|
||||
graphPriceTestResponse(t, "零售价:39元/盒"),
|
||||
}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 1 || model.calls != 2 {
|
||||
t.Fatalf("supported price correction failed: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
if !reflect.DeepEqual(graph.Node[0].Attributes, []string{"零售价:39元/盒"}) {
|
||||
t.Fatal("corrected supported price was not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryExtractionDoesNotRequirePriceProvenance(t *testing.T) {
|
||||
model := &extractionResponseChat{response: graphPriceTestResponse(t, "零售价:39元/盒")}
|
||||
extractor := NewExtractor(model, &types.PromptTemplateStructured{Description: "Extract query entities"})
|
||||
graph, err := extractor.Extract(context.Background(), "查询甲产品零售价")
|
||||
if err != nil || graph == nil || model.calls != 1 {
|
||||
t.Fatalf("graph-only price validation changed query extraction: %+v, %v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// These are labelled source lists, not medical classifications. All offsets
|
||||
// refer to the unchanged chunk; normalized text is used only for comparison.
|
||||
var graphListHeadingPattern = regexp.MustCompile(`(?m)[〔〖【\[][^〔〖【\[〕〗】\]\n]{1,40}[〕〗】\]]|^[ \t]*[^\n::〔〖【\[]{1,30}[::]`)
|
||||
var graphListPredicatePattern = regexp.MustCompile(`谨\s*慎\s*使\s*用|慎\s*[用甩](?:\s*于)?|不\s*适\s*[宜用合](?:\s*于)?|不\s*宜(?:\s*于)?|禁\s*止\s*使\s*用|禁\s*用(?:\s*于)?|不\s*[得可]\s*使\s*用|不\s*能\s*使\s*用`)
|
||||
var graphListAmbiguityPattern = regexp.MustCompile(`[和及与或但若如除仅]|以及|同时|慎|禁|不适|使用|服用|适用|适宜|用于`)
|
||||
|
||||
type graphRestrictionMember struct {
|
||||
start, end int
|
||||
target string
|
||||
sourceTarget string
|
||||
predicate string
|
||||
label string
|
||||
explicit bool
|
||||
ambiguous bool
|
||||
}
|
||||
|
||||
type graphRestrictionScope struct {
|
||||
start, end, headingEnd int
|
||||
section int
|
||||
ownerText string
|
||||
heading string
|
||||
label string
|
||||
members []graphRestrictionMember
|
||||
ambiguous bool
|
||||
}
|
||||
|
||||
func graphRestrictionLabel(predicate string) string {
|
||||
switch strings.TrimSuffix(graphComparisonText(predicate), "于") {
|
||||
case "慎用", "慎甩", "谨慎使用":
|
||||
return "慎用于"
|
||||
case "不适宜", "不适用", "不适合", "不宜":
|
||||
return "不适用于"
|
||||
case "禁用", "禁止使用", "不得使用", "不可使用", "不能使用":
|
||||
return "禁用于"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func graphRestrictionHeadingLabel(heading string) (string, bool) {
|
||||
name := strings.Trim(graphComparisonText(heading), "〔〖【[〕〗】]::")
|
||||
for _, suffix := range []string{"人群", "对象"} {
|
||||
if !strings.HasSuffix(name, suffix) {
|
||||
continue
|
||||
}
|
||||
predicate := strings.TrimSuffix(name, suffix)
|
||||
label := graphRestrictionLabel(predicate)
|
||||
// An OCR-damaged restrictive heading stays a literal block. It must not
|
||||
// lend a guessed typed predicate to its members.
|
||||
return label, label != "" || strings.HasPrefix(predicate, "不") ||
|
||||
strings.HasPrefix(predicate, "慎") || strings.HasPrefix(predicate, "禁")
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func graphTrimSpan(content string, start, end int) (int, int) {
|
||||
text := content[start:end]
|
||||
left := strings.TrimLeftFunc(text, unicode.IsSpace)
|
||||
start += len(text) - len(left)
|
||||
end = start + len(strings.TrimRightFunc(left, unicode.IsSpace))
|
||||
return start, end
|
||||
}
|
||||
|
||||
func graphRestrictionScopes(content string) []graphRestrictionScope {
|
||||
if !graphHasProductDocumentation(content) {
|
||||
return nil
|
||||
}
|
||||
var scopes []graphRestrictionScope
|
||||
offset := 0
|
||||
for sectionIndex, section := range graphProductSections(content) {
|
||||
headings := graphListHeadingPattern.FindAllStringIndex(section, -1)
|
||||
for index, heading := range headings {
|
||||
label, recognized := graphRestrictionHeadingLabel(section[heading[0]:heading[1]])
|
||||
if !recognized {
|
||||
continue
|
||||
}
|
||||
end := len(section)
|
||||
if index+1 < len(headings) {
|
||||
end = headings[index+1][0]
|
||||
}
|
||||
body := section[heading[1]:end]
|
||||
if boundary := strings.IndexAny(body, "。!?!?"); boundary >= 0 {
|
||||
end = heading[1] + boundary
|
||||
}
|
||||
if boundary := strings.Index(section[heading[1]:end], "\n\n"); boundary >= 0 {
|
||||
end = heading[1] + boundary
|
||||
}
|
||||
start, end := graphTrimSpan(content, offset+heading[0], offset+end)
|
||||
scope := graphRestrictionScope{
|
||||
start: start, end: end, headingEnd: offset + heading[1],
|
||||
section: sectionIndex + 1, ownerText: section[:heading[0]],
|
||||
heading: section[heading[0]:heading[1]], label: label, ambiguous: label == "",
|
||||
}
|
||||
memberStart := scope.headingEnd
|
||||
for i, r := range content[scope.headingEnd:scope.end] {
|
||||
if !strings.ContainsRune("、,,;;", r) {
|
||||
continue
|
||||
}
|
||||
memberEnd := scope.headingEnd + i
|
||||
scope.members = append(scope.members, graphParseRestrictionMember(content, memberStart, memberEnd, label))
|
||||
memberStart = memberEnd + len(string(r))
|
||||
}
|
||||
scope.members = append(scope.members, graphParseRestrictionMember(content, memberStart, scope.end, label))
|
||||
for i, member := range scope.members {
|
||||
scope.ambiguous = scope.ambiguous || member.ambiguous
|
||||
// A suffix predicate after bare coordinated items may govern all of
|
||||
// them. Only a heading can supply the omitted predicate here.
|
||||
if i > 0 && member.explicit && !scope.members[i-1].explicit {
|
||||
scope.ambiguous = true
|
||||
}
|
||||
if member.target != "" && strings.Count(content[scope.headingEnd:scope.end], member.target) != 1 {
|
||||
scope.ambiguous = true
|
||||
}
|
||||
}
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
offset += len(section)
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
func graphParseRestrictionMember(content string, start, end int, headingLabel string) graphRestrictionMember {
|
||||
start, end = graphTrimSpan(content, start, end)
|
||||
member := graphRestrictionMember{start: start, end: end, label: headingLabel}
|
||||
raw := content[start:end]
|
||||
predicates := graphListPredicatePattern.FindAllStringIndex(raw, -1)
|
||||
if len(predicates) == 0 {
|
||||
member.target = raw
|
||||
member.sourceTarget = raw
|
||||
} else if len(predicates) == 1 {
|
||||
predicate := predicates[0]
|
||||
member.explicit = true
|
||||
member.predicate = raw[predicate[0]:predicate[1]]
|
||||
member.label = graphRestrictionLabel(member.predicate)
|
||||
before, after := strings.TrimSpace(raw[:predicate[0]]), strings.TrimSpace(raw[predicate[1]:])
|
||||
switch {
|
||||
case before != "" && after == "":
|
||||
member.sourceTarget = before
|
||||
member.target = strings.TrimSpace(strings.TrimPrefix(before, "对"))
|
||||
case before == "" && after != "":
|
||||
member.target = after
|
||||
member.sourceTarget = after
|
||||
default:
|
||||
member.ambiguous = true
|
||||
}
|
||||
} else {
|
||||
member.ambiguous = true
|
||||
}
|
||||
member.ambiguous = member.ambiguous || member.target == "" || member.label == "" ||
|
||||
graphListAmbiguityPattern.MatchString(graphComparisonText(member.target))
|
||||
return member
|
||||
}
|
||||
|
||||
func graphRestrictionTypeMatches(expected, actual string) bool {
|
||||
return expected == actual || (expected == "禁用于" && actual == "不能用于")
|
||||
}
|
||||
|
||||
func graphRestrictionTargetMatches(member graphRestrictionMember, target string) bool {
|
||||
return target != "" && (target == member.target || target == member.sourceTarget)
|
||||
}
|
||||
|
||||
// Display comparisons remove whitespace only, never case or OCR distinctions.
|
||||
func graphRestrictionDisplayText(value string) string {
|
||||
return strings.Join(strings.Fields(value), "")
|
||||
}
|
||||
|
||||
func graphRestrictionHeadingName(heading string) string {
|
||||
return strings.Trim(graphRestrictionDisplayText(heading), "〔〖【[〕〗】]::")
|
||||
}
|
||||
|
||||
func graphRestrictionDisplayParts(value string) (heading, body string, ok bool) {
|
||||
value = graphRestrictionDisplayText(value)
|
||||
span := graphListHeadingPattern.FindStringIndex(value)
|
||||
if span == nil || span[0] != 0 {
|
||||
return "", "", false
|
||||
}
|
||||
heading, body = value[:span[1]], value[span[1]:]
|
||||
_, ok = graphRestrictionHeadingLabel(heading)
|
||||
return heading, body, ok
|
||||
}
|
||||
|
||||
func graphRestrictionCompleteTargetMatches(scope graphRestrictionScope, target, content string) bool {
|
||||
heading, body, ok := graphRestrictionDisplayParts(target)
|
||||
if !ok || body == "" || body != graphRestrictionDisplayText(content[scope.headingEnd:scope.end]) {
|
||||
return false
|
||||
}
|
||||
sourceHeading := graphRestrictionDisplayText(scope.heading)
|
||||
name := graphRestrictionHeadingName(scope.heading)
|
||||
return heading == sourceHeading || heading == name+":" || heading == name+":"
|
||||
}
|
||||
|
||||
func graphRestrictionSubjectMatches(scope graphRestrictionScope, subject string) bool {
|
||||
return graphGenericProduct(subject) || (graphProductIdentity(subject) != "" &&
|
||||
strings.Contains(graphProductIdentity(scope.ownerText), graphProductIdentity(subject)))
|
||||
}
|
||||
|
||||
func graphRestrictionEvidenceTouches(scope graphRestrictionScope, evidence, content string) bool {
|
||||
if evidence == "" {
|
||||
return false
|
||||
}
|
||||
for offset := 0; offset < len(content); {
|
||||
index := strings.Index(content[offset:], evidence)
|
||||
if index < 0 {
|
||||
break
|
||||
}
|
||||
start := offset + index
|
||||
end := start + len(evidence)
|
||||
if start < scope.end && end > scope.headingEnd {
|
||||
return true
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Independent source/target anchors allow semantic diagnostics even when the
|
||||
// model changed its quotation. They never repair that quotation or output edge.
|
||||
func graphRestrictionRelation(content string, relation *types.GraphRelation, scopes []graphRestrictionScope) ([]string, bool, error) {
|
||||
if relation == nil || len(scopes) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
target := strings.TrimSpace(relation.Node2)
|
||||
type match struct {
|
||||
scope graphRestrictionScope
|
||||
member int
|
||||
whole bool
|
||||
}
|
||||
var candidates []match
|
||||
related := false
|
||||
for _, scope := range scopes {
|
||||
whole := graphRestrictionCompleteTargetMatches(scope, target, content)
|
||||
memberIndex := -1
|
||||
for i, member := range scope.members {
|
||||
if graphRestrictionTargetMatches(member, target) {
|
||||
memberIndex = i
|
||||
}
|
||||
}
|
||||
touches := graphRestrictionEvidenceTouches(scope, relation.Evidence, content)
|
||||
useLabel := graphPositiveUseLabel(relation.Type) || relation.Type == "慎用于" ||
|
||||
relation.Type == "禁用于" || relation.Type == "不适用于" || relation.Type == "不能用于"
|
||||
if !whole && memberIndex < 0 && !(touches && (useLabel || relation.Type == "关联" || relation.Type == "约束")) {
|
||||
continue
|
||||
}
|
||||
related = true
|
||||
if graphRestrictionSubjectMatches(scope, relation.Node1) {
|
||||
candidates = append(candidates, match{scope: scope, member: memberIndex, whole: whole})
|
||||
}
|
||||
}
|
||||
if !related {
|
||||
return nil, false, nil
|
||||
}
|
||||
if len(candidates) > 1 && strings.Contains(content, relation.Evidence) && relation.Evidence != "" {
|
||||
var anchored []match
|
||||
for _, candidate := range candidates {
|
||||
if graphRestrictionEvidenceTouches(candidate.scope, relation.Evidence, content) {
|
||||
anchored = append(anchored, candidate)
|
||||
}
|
||||
}
|
||||
candidates = anchored
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
return nil, true, fmt.Errorf("%w: restriction list has ambiguous target or product scope; preserve a complete literal list constraint with source-specific evidence", ErrGraphNeedsReview)
|
||||
}
|
||||
candidate := candidates[0]
|
||||
scope := candidate.scope
|
||||
if candidate.whole && relation.Type == "约束" {
|
||||
if !strings.Contains(relation.Evidence, content[scope.start:scope.end]) {
|
||||
return nil, true, fmt.Errorf("%w: complete restriction constraint requires unchanged heading and list evidence in source section %d", ErrGraphNeedsReview, scope.section)
|
||||
}
|
||||
return []string{graphComparisonText(content[scope.start:scope.end])}, true, nil
|
||||
}
|
||||
if scope.ambiguous || candidate.member < 0 {
|
||||
return nil, true, fmt.Errorf("%w: ambiguous restriction list in source section %d requires its complete literal block as a constraint, not an inferred population edge", ErrGraphNeedsReview, scope.section)
|
||||
}
|
||||
member := scope.members[candidate.member]
|
||||
var failures []error
|
||||
if !graphRestrictionTypeMatches(member.label, relation.Type) {
|
||||
failures = append(failures, fmt.Errorf("%w: restriction strength mismatch in source section %d item %d: expected %s from its own predicate, not %s from a neighboring item", ErrGraphNeedsReview, scope.section, candidate.member+1, member.label, relation.Type))
|
||||
}
|
||||
claim := content[scope.start:member.end]
|
||||
clause := scope.heading + content[member.start:member.end]
|
||||
if member.explicit {
|
||||
claim = content[member.start:member.end]
|
||||
clause = claim
|
||||
}
|
||||
if !strings.Contains(relation.Evidence, claim) {
|
||||
failures = append(failures, fmt.Errorf("%w: restriction evidence must include the unchanged governing predicate and target in source section %d item %d; a heading-governed item needs its heading and intervening text", ErrGraphNeedsReview, scope.section, candidate.member+1))
|
||||
}
|
||||
return []string{graphComparisonText(clause)}, true, errors.Join(failures...)
|
||||
}
|
||||
|
||||
func validateGraphRestrictionCoverage(graph *types.GraphData, content string) error {
|
||||
scopes := graphRestrictionScopes(content)
|
||||
var failures []error
|
||||
for _, scope := range scopes {
|
||||
covered := make([]bool, len(scope.members))
|
||||
complete := false
|
||||
for _, relation := range graph.Relation {
|
||||
if relation == nil || relation.Evidence == "" || !strings.Contains(content, relation.Evidence) ||
|
||||
!graphRestrictionEvidenceTouches(scope, relation.Evidence, content) {
|
||||
continue
|
||||
}
|
||||
_, scoped, err := graphRestrictionRelation(content, relation, scopes)
|
||||
if !scoped || err != nil || !graphRestrictionSubjectMatches(scope, relation.Node1) {
|
||||
continue
|
||||
}
|
||||
if relation.Type == "约束" && graphRestrictionCompleteTargetMatches(scope, relation.Node2, content) &&
|
||||
strings.Contains(relation.Evidence, content[scope.start:scope.end]) {
|
||||
complete = true
|
||||
break
|
||||
}
|
||||
for i, member := range scope.members {
|
||||
if graphRestrictionTargetMatches(member, strings.TrimSpace(relation.Node2)) && graphRestrictionTypeMatches(member.label, relation.Type) {
|
||||
covered[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !complete {
|
||||
complete = !scope.ambiguous
|
||||
for _, present := range covered {
|
||||
complete = complete && present
|
||||
}
|
||||
}
|
||||
if !complete {
|
||||
failures = append(failures, fmt.Errorf("%w: restriction list in source section %d is not fully covered; preserve each unambiguous member with its own strength or the complete literal heading and list as one constraint", ErrGraphNeedsReview, scope.section))
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
|
||||
func validateGraphRestrictionAttributes(graph *types.GraphData, content string) error {
|
||||
scopes := graphRestrictionScopes(content)
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
sectionCount := len(graphProductSections(content))
|
||||
var failures []error
|
||||
for i, node := range graph.Node {
|
||||
if node == nil {
|
||||
continue
|
||||
}
|
||||
_, headingNode := graphRestrictionHeadingLabel(node.Name)
|
||||
for j, attribute := range node.Attributes {
|
||||
heading, _, labelled := graphRestrictionDisplayParts(attribute)
|
||||
if !headingNode && !labelled {
|
||||
continue
|
||||
}
|
||||
name := graphRestrictionHeadingName(heading)
|
||||
if headingNode {
|
||||
name = graphRestrictionHeadingName(node.Name)
|
||||
}
|
||||
var candidates []graphRestrictionScope
|
||||
for _, scope := range scopes {
|
||||
if graphRestrictionHeadingName(scope.heading) != name {
|
||||
continue
|
||||
}
|
||||
// Attributes have no evidence anchor to disambiguate a generic
|
||||
// product name across sections or a repeated standalone heading.
|
||||
if headingNode || (graphRestrictionSubjectMatches(scope, node.Name) &&
|
||||
(!graphGenericProduct(node.Name) || sectionCount == 1)) {
|
||||
candidates = append(candidates, scope)
|
||||
}
|
||||
}
|
||||
if len(candidates) != 1 {
|
||||
failures = append(failures, fmt.Errorf("%w: entity %d attribute %d has an ambiguous or unsupported restriction product/heading scope; require one literal local source block", ErrGraphNeedsReview, i+1, j+1))
|
||||
continue
|
||||
}
|
||||
scope := candidates[0]
|
||||
supported := graphRestrictionCompleteTargetMatches(scope, attribute, content)
|
||||
if headingNode && !labelled {
|
||||
matches := 0
|
||||
for _, member := range scope.members {
|
||||
if graphRestrictionDisplayText(attribute) != "" &&
|
||||
graphRestrictionDisplayText(attribute) == graphRestrictionDisplayText(content[member.start:member.end]) {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
supported = matches == 1
|
||||
}
|
||||
if !supported {
|
||||
failures = append(failures, fmt.Errorf("%w: entity %d attribute %d has unsupported restriction text in source section %d; preserve literal OCR spelling, each local predicate and the complete labelled list", ErrGraphNeedsReview, i+1, j+1, scope.section))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestGraphRestrictionCompleteDisplayKeepsContentAndEvidence(t *testing.T) {
|
||||
const block = "〖 不适宜人群 〕 对甲类慎用 、 乙丿 IJ 类 、 丙类(限成人)"
|
||||
source := "产品名称:甲品\n" + block + "。"
|
||||
for _, tc := range []struct {
|
||||
name, target string
|
||||
wantErr bool
|
||||
}{
|
||||
{"unchanged block", block, false},
|
||||
{"colon display", "不适宜人群:对甲类慎用、乙丿 IJ 类、丙类(限成人)", false},
|
||||
{"ascii colon and whitespace", " 不适 宜人群 :\n对甲类慎用 、 乙丿\tIJ 类 、 丙类(限成人) ", false},
|
||||
{"source brackets with whitespace", "〖不适宜人群〕对甲类慎用、乙丿IJ类、丙类(限成人)", false},
|
||||
{"different bracket", strings.Replace(block, "〖", "〔", 1), true},
|
||||
{"unpaired bracket", strings.TrimPrefix(block, "〖"), true},
|
||||
{"OCR correction", strings.Replace(block, "乙丿 IJ 类", "乙儿童类", 1), true},
|
||||
{"OCR compatibility replacement", strings.Replace(block, "不适宜", "不适宜", 1), true},
|
||||
{"case folding", strings.Replace(block, "IJ", "ij", 1), true},
|
||||
{"member omitted", strings.Replace(block, "乙丿 IJ 类 、 ", "", 1), true},
|
||||
{"members reordered", "不适宜人群:乙丿 IJ 类、对甲类慎用、丙类(限成人)", true},
|
||||
{"body delimiter changed", strings.ReplaceAll(block, "、", ","), true},
|
||||
{"predicate strengthened", strings.Replace(block, "慎用", "禁用", 1), true},
|
||||
{"predicate dropped", strings.Replace(block, "慎用", "", 1), true},
|
||||
{"heading weakened", strings.Replace(block, "不适宜", "慎用", 1), true},
|
||||
{"qualifier dropped", strings.Replace(block, "(限成人)", "", 1), true},
|
||||
{"generic label", "不适宜人群", true},
|
||||
{"empty label", "", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
relation := &types.GraphRelation{Node1: "甲品", Node2: tc.target, Type: "约束", Evidence: block}
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{relation}}
|
||||
err := validateGraphRelationEvidence(graph, source)
|
||||
coverageErr := validateGraphRestrictionCoverage(graph, source)
|
||||
if (err != nil) != tc.wantErr || (coverageErr != nil) != tc.wantErr {
|
||||
t.Fatalf("validation = %v, coverage = %v, want rejection %v", err, coverageErr, tc.wantErr)
|
||||
}
|
||||
if relation.Node2 != tc.target || relation.Evidence != block {
|
||||
t.Fatal("display comparison mutated the model output")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, evidence := range []string{
|
||||
strings.Replace(block, "〖", "〔", 1),
|
||||
graphRestrictionDisplayText(block),
|
||||
"对甲类慎用",
|
||||
} {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: "不适宜人群:对甲类慎用、乙丿 IJ 类、丙类(限成人)", Type: "约束", Evidence: evidence}}}
|
||||
if err := validateGraphRelationEvidence(graph, source); err == nil {
|
||||
t.Fatal("display equivalence waived exact complete evidence")
|
||||
}
|
||||
if err := validateGraphRestrictionCoverage(graph, source); !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatal("incomplete or changed evidence satisfied coverage")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionAttributesAreLiteralAndSourceScoped(t *testing.T) {
|
||||
const block = "〔 不适宜人群 〕 对甲类慎用 、 乙丿 IJ 类 、 丙类"
|
||||
source := "产品名称:甲品\n" + block + "。"
|
||||
for _, tc := range []struct {
|
||||
name, node, attribute string
|
||||
wantErr bool
|
||||
}{
|
||||
{"source block", "甲品", block, false},
|
||||
{"label display", "甲品", "不适宜人群:对甲类慎用、乙丿 IJ 类、丙类", false},
|
||||
{"local generic product", "本品", block, false},
|
||||
{"ordinary descriptive attribute", "甲品", "普通说明", false},
|
||||
{"heading member", "不适宜人群", "乙丿 IJ 类", false},
|
||||
{"heading local predicate", "不适宜人群", "对甲类慎用", false},
|
||||
{"heading member whitespace", "不适宜人群", "乙丿IJ类", false},
|
||||
{"heading full list", "不适宜人群", block, false},
|
||||
{"product OCR correction", "甲品", "不适宜人群:对甲类慎用、乙儿童类、丙类", true},
|
||||
{"standalone OCR correction", "不适宜人群", "乙儿童类", true},
|
||||
{"case changed", "不适宜人群", "乙丿 ij 类", true},
|
||||
{"standalone dropped predicate", "不适宜人群", "甲类", true},
|
||||
{"standalone changed predicate", "不适宜人群", "对甲类禁用", true},
|
||||
{"standalone fabricated predicate", "不适宜人群", "丙类慎用", true},
|
||||
{"changed heading strength", "甲品", "慎用人群:对甲类慎用、乙丿 IJ 类、丙类", true},
|
||||
{"changed heading entity", "慎用人群", "丙类", true},
|
||||
{"product dropped member", "甲品", "不适宜人群:对甲类慎用、乙丿 IJ 类", true},
|
||||
{"product reordered members", "甲品", "不适宜人群:对甲类慎用、丙类、乙丿 IJ 类", true},
|
||||
{"product changed delimiter", "甲品", "不适宜人群:对甲类慎用,乙丿 IJ 类,丙类", true},
|
||||
{"unknown owner", "乙品", block, true},
|
||||
{"list member is not owner", "丙类", block, true},
|
||||
{"standalone empty member", "不适宜人群", "", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
node := &types.GraphNode{Name: tc.node, Attributes: []string{tc.attribute}}
|
||||
err := validateGraphRestrictionAttributes(&types.GraphData{Node: []*types.GraphNode{node}}, source)
|
||||
if (err != nil) != tc.wantErr || (tc.wantErr && (!errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), "entity 1 attribute 1"))) {
|
||||
t.Fatalf("validation = %v, want indexed rejection %v", err, tc.wantErr)
|
||||
}
|
||||
if node.Name != tc.node || node.Attributes[0] != tc.attribute {
|
||||
t.Fatal("attribute validation rewrote an entity")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionAttributesCannotBorrowAnotherProductBlock(t *testing.T) {
|
||||
const first = "〔不适宜人群〕甲类、乙类"
|
||||
const second = "〔不适宜人群〕丙类、丁类"
|
||||
source := "产品名称:甲品\n" + first + "。\n\n产品名称:乙品\n" + second + "。"
|
||||
for _, tc := range []struct {
|
||||
node, attribute string
|
||||
wantErr bool
|
||||
}{
|
||||
{"甲品", first, false},
|
||||
{"乙品", second, false},
|
||||
{"甲品", second, true},
|
||||
{"乙品", first, true},
|
||||
{"本品", first, true},
|
||||
{"本品(前段未名产品)", second, true},
|
||||
{"不适宜人群", "甲类", true},
|
||||
{"不适宜人群", first, true},
|
||||
} {
|
||||
graph := &types.GraphData{Node: []*types.GraphNode{{Name: tc.node, Attributes: []string{tc.attribute}}}}
|
||||
if err := validateGraphRestrictionAttributes(graph, source); (err != nil) != tc.wantErr {
|
||||
t.Fatalf("node=%s, error=%v, want rejection %v", tc.node, err, tc.wantErr)
|
||||
}
|
||||
}
|
||||
// A second product does not need its own list to make an evidence-free
|
||||
// generic product attribute ambiguous.
|
||||
source = "产品名称:甲品\n" + first + "。\n\n产品名称:乙品\n普通说明。"
|
||||
graph := &types.GraphData{Node: []*types.GraphNode{{Name: "本品", Attributes: []string{first}}}}
|
||||
if err := validateGraphRestrictionAttributes(graph, source); !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatal("generic attribute borrowed the only list from an ambiguous product")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionConstraintCannotMaskDuplicateAttributeErrors(t *testing.T) {
|
||||
const block = "〔不适宜人群〕对甲类慎用、乙丿 IJ 类"
|
||||
source := "产品名称:甲品\n" + block + "。"
|
||||
graph := &types.GraphData{
|
||||
Node: []*types.GraphNode{
|
||||
{Name: "甲品", Attributes: []string{"普通说明", "不适宜人群:对甲类慎用、乙儿童类"}},
|
||||
{Name: "不适宜人群", Attributes: []string{"对甲类慎用", "乙儿童类"}},
|
||||
},
|
||||
Relation: []*types.GraphRelation{{Node1: "甲品", Node2: "不适宜人群:对甲类慎用、乙丿 IJ 类", Type: "约束", Evidence: block}},
|
||||
}
|
||||
response := graphCoverageResponse(t, graph)
|
||||
accepted, err := NewFormater().parseGraph(context.Background(), response.Content, &source)
|
||||
if accepted != nil || !errors.Is(err, ErrGraphNeedsReview) ||
|
||||
!strings.Contains(err.Error(), "entity 1 attribute 2") || !strings.Contains(err.Error(), "entity 2 attribute 2") ||
|
||||
strings.Count(err.Error(), "unsupported restriction text") != 2 || strings.Contains(err.Error(), "relation object") {
|
||||
t.Fatalf("complete constraint concealed duplicate unsupported attributes: %v", err)
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// Private artifacts are read only. Changes below create separate in-memory
|
||||
// regression candidates, never a repaired saved response or production graph.
|
||||
func TestGraphRestrictionPrivateReplayKeepsQuoteAndStrengthRejection(t *testing.T) {
|
||||
path := os.Getenv("GRAPH_RESTRICTION_REPLAY_REPORT")
|
||||
if path == "" {
|
||||
t.Skip("private restriction replay report not configured")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal("cannot read private restriction replay report")
|
||||
}
|
||||
digest := func(value []byte) string {
|
||||
hash := sha256.Sum256(value)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
const reportSHA = "e6ef1e6affdb038c8d7424a3903441658fab0f40a49ffdfa76efc0d31b35919a"
|
||||
var report struct {
|
||||
ChunkID string `json:"chunk_id"`
|
||||
Source string `json:"source_content"`
|
||||
SourceSHA256 string `json:"source_sha256"`
|
||||
RejectedContent string `json:"rejected_model_content"`
|
||||
ProductionWrite bool `json:"production_write"`
|
||||
Passed bool `json:"passed"`
|
||||
}
|
||||
if digest(raw) != reportSHA || json.Unmarshal(raw, &report) != nil ||
|
||||
report.ChunkID != "e4261987-382b-4e7a-8a72-3e741dcfb75f" ||
|
||||
report.SourceSHA256 != "6004588d8162144f3465bec858b74759d40c1af1ba4f98b99e5c1ef990172a02" ||
|
||||
digest([]byte(report.Source)) != report.SourceSHA256 ||
|
||||
digest([]byte(report.RejectedContent)) != "3148070f609d5475d70c5be184fb03a9487c7d24aa530680c64bc31c1cfacf39" ||
|
||||
report.ProductionWrite || report.Passed {
|
||||
t.Fatal("private restriction evidence differs from the reviewed rejected candidate")
|
||||
}
|
||||
formatter := NewFormater()
|
||||
graph, err := formatter.parseGraph(context.Background(), report.RejectedContent, &report.Source)
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) ||
|
||||
strings.Count(err.Error(), "requires exact evidence") != 4 ||
|
||||
strings.Count(err.Error(), "restriction strength mismatch") != 3 {
|
||||
t.Fatal("original private replay did not independently reject all reviewed quote and strength failures")
|
||||
}
|
||||
items, err := formatter.parseOutput(context.Background(), report.RejectedContent)
|
||||
if err != nil {
|
||||
t.Fatal("cannot decode private response")
|
||||
}
|
||||
relationIndex, quoteChanges := 0, 0
|
||||
for _, item := range items {
|
||||
if item["entity1"] == nil {
|
||||
continue
|
||||
}
|
||||
if relationIndex >= 3 && relationIndex <= 6 {
|
||||
quote, ok := item["evidence"].(string)
|
||||
if !ok || !strings.HasPrefix(quote, "(") || strings.Contains(report.Source, quote) {
|
||||
t.Fatal("reviewed quote mutation changed shape")
|
||||
}
|
||||
quote = "〔" + strings.TrimPrefix(quote, "(")
|
||||
if !strings.Contains(report.Source, quote) {
|
||||
t.Fatal("reviewed delimiter-only test candidate is not source-exact")
|
||||
}
|
||||
item["evidence"] = quote
|
||||
quoteChanges++
|
||||
}
|
||||
relationIndex++
|
||||
}
|
||||
if quoteChanges != 4 || relationIndex != 8 {
|
||||
t.Fatal("reviewed response relation shape changed")
|
||||
}
|
||||
quoteOnly, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatal("cannot encode offline quote-only test candidate")
|
||||
}
|
||||
graph, err = formatter.parseGraph(context.Background(), string(quoteOnly), &report.Source)
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) ||
|
||||
strings.Contains(err.Error(), "requires exact evidence") ||
|
||||
strings.Count(err.Error(), "restriction strength mismatch") != 3 {
|
||||
t.Fatal("delimiter-only offline candidate hid the three strength errors")
|
||||
}
|
||||
relationIndex = 0
|
||||
for _, item := range items {
|
||||
if item["entity1"] == nil {
|
||||
continue
|
||||
}
|
||||
if relationIndex >= 4 && relationIndex <= 6 {
|
||||
item["relation"] = "不适用于"
|
||||
}
|
||||
relationIndex++
|
||||
}
|
||||
corrected, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatal("cannot encode offline scoped test candidate")
|
||||
}
|
||||
graph, err = formatter.parseGraph(context.Background(), string(corrected), &report.Source)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 8 {
|
||||
var categories []string
|
||||
if err != nil {
|
||||
for _, category := range []string{"restriction", "strength", "scope", "prohibition", "caution", "condition", "storage", "quantity", "direction", "identity", "price", "product", "negative", "source evidence", "supported", "unsupported", "missing", "exact evidence"} {
|
||||
if strings.Contains(err.Error(), category) {
|
||||
categories = append(categories, category)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("source-exact scoped offline candidate failed validation (categories=%v)", categories)
|
||||
}
|
||||
caution, unsuitable, constraints, attributes := 0, 0, 0, 0
|
||||
for _, relation := range graph.Relation {
|
||||
if !strings.Contains(report.Source, relation.Evidence) {
|
||||
t.Fatal("offline scoped candidate changed evidence")
|
||||
}
|
||||
switch relation.Type {
|
||||
case "慎用于":
|
||||
caution++
|
||||
case "不适用于":
|
||||
unsuitable++
|
||||
case "约束":
|
||||
constraints++
|
||||
}
|
||||
}
|
||||
for _, node := range graph.Node {
|
||||
attributes += len(node.Attributes)
|
||||
}
|
||||
if caution != 1 || unsuitable != 3 || constraints != 4 || attributes != 10 {
|
||||
t.Fatal("offline scoped correction lost reviewed assertions or attributes")
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil || digest(after) != reportSHA {
|
||||
t.Fatal("private replay artifact changed during validation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionPrivateDisplayReplayKeepsAttributeRejection(t *testing.T) {
|
||||
path := os.Getenv("GRAPH_RESTRICTION_DISPLAY_REPLAY_REPORT")
|
||||
if path == "" {
|
||||
t.Skip("private restriction display replay report not configured")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal("cannot read private display replay report")
|
||||
}
|
||||
digest := func(value []byte) string {
|
||||
hash := sha256.Sum256(value)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
const reportSHA = "956548002116c2c50d05255e533f99d5911d97dbd7c44d8bbf1c21354e9b5f15"
|
||||
var report struct {
|
||||
ChunkID string `json:"chunk_id"`
|
||||
Source string `json:"source_content"`
|
||||
SourceSHA256 string `json:"source_sha256"`
|
||||
RejectedContent string `json:"rejected_model_content"`
|
||||
ProductionWrite bool `json:"production_write"`
|
||||
Passed bool `json:"passed"`
|
||||
}
|
||||
if digest(raw) != reportSHA || json.Unmarshal(raw, &report) != nil ||
|
||||
report.ChunkID != "e4261987-382b-4e7a-8a72-3e741dcfb75f" ||
|
||||
report.SourceSHA256 != "6004588d8162144f3465bec858b74759d40c1af1ba4f98b99e5c1ef990172a02" ||
|
||||
digest([]byte(report.Source)) != report.SourceSHA256 ||
|
||||
digest([]byte(report.RejectedContent)) != "4bdde301dedded23f38c6e999642ea8652e1c0ef0554580b1a760bccbebe8dba" ||
|
||||
report.ProductionWrite || report.Passed {
|
||||
t.Fatal("private display evidence differs from the reviewed rejected response")
|
||||
}
|
||||
formatter := NewFormater()
|
||||
graph, err := formatter.parseGraph(context.Background(), report.RejectedContent, &report.Source)
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) ||
|
||||
!strings.Contains(err.Error(), "entity 2 attribute 3") ||
|
||||
!strings.Contains(err.Error(), "entity 3 attribute 2") ||
|
||||
strings.Count(err.Error(), "unsupported restriction text") != 2 ||
|
||||
strings.Contains(err.Error(), "relation object") || strings.Contains(err.Error(), "not fully covered") {
|
||||
t.Fatal("immutable response must reject both reviewed attributes while accepting the supported display relation")
|
||||
}
|
||||
items, err := formatter.parseOutput(context.Background(), report.RejectedContent)
|
||||
if err != nil || len(items) != 7 {
|
||||
t.Fatal("private display response changed shape")
|
||||
}
|
||||
last := items[6]
|
||||
relation := &types.GraphRelation{
|
||||
Node1: last["entity1"].(string), Node2: last["entity2"].(string),
|
||||
Type: last["relation"].(string), Evidence: last["evidence"].(string),
|
||||
}
|
||||
before := *relation
|
||||
isolated := &types.GraphData{Relation: []*types.GraphRelation{relation}}
|
||||
if err := errors.Join(validateGraphRelationEvidence(isolated, report.Source), validateGraphRestrictionCoverage(isolated, report.Source)); err != nil || *relation != before {
|
||||
t.Fatal("isolated supported display relation was rejected or rewritten")
|
||||
}
|
||||
const usageTarget = "每日1瓶,开瓶即饮"
|
||||
const usageEvidence = "每日 1 瓶 , 开瓶即饮"
|
||||
if !strings.Contains(report.Source, usageEvidence) {
|
||||
t.Fatal("reviewed usage source changed")
|
||||
}
|
||||
for _, item := range items {
|
||||
if target, ok := item["entity2"].(string); ok && strings.Contains(graphRestrictionDisplayText(target), "开瓶即饮") {
|
||||
t.Fatal("saved response no longer has the reviewed usage-constraint omission")
|
||||
}
|
||||
}
|
||||
// This is a separate full-review candidate, not a repair of the saved
|
||||
// response. Both unsupported attributes and missing usage must be handled.
|
||||
changed := 0
|
||||
for _, item := range items {
|
||||
attributes, _ := item["entity_attributes"].([]interface{})
|
||||
for i, attribute := range attributes {
|
||||
text, ok := attribute.(string)
|
||||
if ok && strings.Contains(text, "婴幼儿") {
|
||||
attributes[i] = strings.ReplaceAll(text, "婴幼儿", "婴幼丿 IJ")
|
||||
changed++
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed != 2 {
|
||||
t.Fatal("reviewed duplicate attribute shape changed")
|
||||
}
|
||||
items = append(items, map[string]interface{}{
|
||||
"entity1": relation.Node1, "entity2": usageTarget, "relation": "约束", "evidence": usageEvidence,
|
||||
})
|
||||
candidate, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatal("cannot encode separate full-review candidate")
|
||||
}
|
||||
graph, err = formatter.parseGraph(context.Background(), string(candidate), &report.Source)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 5 {
|
||||
t.Fatal("source-grounded full-review candidate failed validation")
|
||||
}
|
||||
attributes, usage, completeList := 0, 0, 0
|
||||
for _, node := range graph.Node {
|
||||
attributes += len(node.Attributes)
|
||||
}
|
||||
for _, edge := range graph.Relation {
|
||||
if edge.Type != "约束" || !strings.Contains(report.Source, edge.Evidence) {
|
||||
t.Fatal("full-review candidate changed source evidence or relation type")
|
||||
}
|
||||
if edge.Node2 == usageTarget && edge.Evidence == usageEvidence && edge.Node1 == relation.Node1 {
|
||||
usage++
|
||||
}
|
||||
if *edge == before {
|
||||
completeList++
|
||||
}
|
||||
}
|
||||
if attributes != 13 || usage != 1 || completeList != 1 {
|
||||
t.Fatal("full-review candidate lost attributes, usage or the unchanged display relation")
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil || digest(after) != reportSHA {
|
||||
t.Fatal("immutable private display artifact changed during replay")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
const graphRestrictionTestBlock = "〔 不适宜人群 〕 对甲类慎用 、 乙类 、 丙类"
|
||||
const graphRestrictionTestSource = "产品名称:甲品\n" + graphRestrictionTestBlock + "。"
|
||||
|
||||
func TestGraphRestrictionStrengthBelongsToItsOwnItem(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, target, label, evidence, wantErr string
|
||||
}{
|
||||
{"local caution", "甲类", "慎用于", "对甲类慎用", ""},
|
||||
{"local caution preserves source preposition", "对甲类", "慎用于", "对甲类慎用", ""},
|
||||
{"local caution with full list evidence", "甲类", "慎用于", graphRestrictionTestBlock, ""},
|
||||
{"first inherited non suitability", "乙类", "不适用于", graphRestrictionTestBlock, ""},
|
||||
{"last inherited non suitability", "丙类", "不适用于", graphRestrictionTestBlock, ""},
|
||||
{"neighbor caution cannot propagate", "乙类", "慎用于", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"later neighbor caution cannot propagate", "丙类", "慎用于", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"caution cannot strengthen to non suitability", "甲类", "不适用于", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"caution cannot strengthen to prohibition", "甲类", "禁用于", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"non suitability cannot strengthen to prohibition", "乙类", "禁用于", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"permission cannot replace restriction", "乙类", "适用于", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"association cannot replace restriction", "乙类", "关联", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"heading predicate cannot be cropped", "乙类", "不适用于", "乙类", "governing predicate"},
|
||||
{"local predicate cannot be cropped", "甲类", "慎用于", "甲类", "governing predicate"},
|
||||
{"whole source constraint", graphRestrictionTestBlock, "约束", graphRestrictionTestBlock, ""},
|
||||
{"truncated constraint", "乙类", "约束", graphRestrictionTestBlock, "restriction strength mismatch"},
|
||||
{"changed target spelling", "乙组", "不适用于", graphRestrictionTestBlock, "ambiguous restriction list"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: tc.target, Type: tc.label, Evidence: tc.evidence}}}
|
||||
err := validateGraphRelationEvidence(graph, graphRestrictionTestSource)
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if !errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want review containing %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionScopesRetainSourceSpans(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
block, target, label string
|
||||
}{
|
||||
{"【慎用人群】甲类,乙类", "乙类", "慎用于"},
|
||||
{"〖禁用对象〕甲类;乙类", "乙类", "禁用于"},
|
||||
{"[不适用人群] 丙类、乙类", "丙类", "不适用于"},
|
||||
{"不适宜人群:丙类;乙类", "丙类", "不适用于"},
|
||||
{"〔 不适宜人群 〕 对甲类慎甩,乙丿 IJ 类、丙类", "乙丿 IJ 类", "不适用于"},
|
||||
{"〔不适合对象〕甲类\n 、\t乙类", "乙类", "不适用于"},
|
||||
{"〔慎用人群〕甲类禁用、乙类", "甲类", "禁用于"},
|
||||
{"〔慎用人群〕甲类禁用、乙类", "乙类", "慎用于"},
|
||||
} {
|
||||
t.Run(tc.block, func(t *testing.T) {
|
||||
source := "产品名称:甲品\n" + tc.block + "\n〔贮存条件〕阴凉处保存。"
|
||||
scopes := graphRestrictionScopes(source)
|
||||
if len(scopes) != 1 || source[scopes[0].start:scopes[0].end] != tc.block {
|
||||
t.Fatal("restriction list boundaries changed source text")
|
||||
}
|
||||
relation := &types.GraphRelation{Node1: "甲品", Node2: tc.target, Type: tc.label, Evidence: tc.block}
|
||||
if err := validateGraphRelationEvidence(&types.GraphData{Relation: []*types.GraphRelation{relation}}, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if relation.Evidence != tc.block || relation.Node2 != tc.target {
|
||||
t.Fatal("validation mutated source text or target")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionAmbiguityRequiresCompleteLiteralBlock(t *testing.T) {
|
||||
for _, block := range []string{
|
||||
"〔不适宜人群〕甲类、乙类慎用",
|
||||
"〔不适宜人群〕甲类、甲类",
|
||||
"〔不适宜人群〕甲类和乙类慎用",
|
||||
"〔不适宜人群〕甲类慎甪、乙类",
|
||||
"〔不适冝人群〕甲类、乙类",
|
||||
"〔不适宜人群〕甲类(特殊条件除外)、乙类",
|
||||
} {
|
||||
t.Run(block, func(t *testing.T) {
|
||||
source := "本品\n" + block + "。"
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "本品", Node2: "甲类", Type: "不适用于", Evidence: block}}}
|
||||
if err := validateGraphRelationEvidence(graph, source); !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatalf("ambiguous list accepted: %v", err)
|
||||
}
|
||||
graph.Relation[0].Node2, graph.Relation[0].Type = block, "约束"
|
||||
if err := validateGraphRelationEvidence(graph, source); err != nil {
|
||||
t.Fatal("literal constraint rejected", err)
|
||||
}
|
||||
if err := validateGraphRestrictionCoverage(graph, source); err != nil {
|
||||
t.Fatal("complete literal constraint lost coverage", err)
|
||||
}
|
||||
graph.Relation[0].Node2 = strings.TrimPrefix(block, "〔")
|
||||
if err := validateGraphRestrictionCoverage(graph, source); !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatal("rewritten target satisfied full-block coverage")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionCoverageCannotDropOrHideMembers(t *testing.T) {
|
||||
valid := []*types.GraphRelation{
|
||||
{Node1: "甲品", Node2: "甲类", Type: "慎用于", Evidence: "对甲类慎用"},
|
||||
{Node1: "甲品", Node2: "乙类", Type: "不适用于", Evidence: graphRestrictionTestBlock},
|
||||
{Node1: "甲品", Node2: "丙类", Type: "不适用于", Evidence: graphRestrictionTestBlock},
|
||||
}
|
||||
if err := validateGraphRestrictionCoverage(&types.GraphData{Relation: valid}, graphRestrictionTestSource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid[0].Node2 = "对甲类"
|
||||
if err := validateGraphRestrictionCoverage(&types.GraphData{Relation: valid}, graphRestrictionTestSource); err != nil {
|
||||
t.Fatal("literal source preposition lost coverage", err)
|
||||
}
|
||||
valid[0].Node2 = "甲类"
|
||||
for missing := range valid {
|
||||
relations := append([]*types.GraphRelation{}, valid[:missing]...)
|
||||
relations = append(relations, valid[missing+1:]...)
|
||||
graph := &types.GraphData{Node: []*types.GraphNode{{Name: "甲品", Attributes: []string{graphRestrictionTestBlock}}}, Relation: relations}
|
||||
if err := validateGraphRestrictionCoverage(graph, graphRestrictionTestSource); !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatal("attributes or neighboring edges concealed a missing list member")
|
||||
}
|
||||
}
|
||||
const storage = "阴凉处保存"
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: storage, Type: "约束", Evidence: storage}}}
|
||||
response := graphCoverageResponse(t, graph)
|
||||
source := graphRestrictionTestSource + "\n" + storage + "。"
|
||||
if accepted, err := NewFormater().parseGraph(context.Background(), response.Content, &source); accepted != nil || !errors.Is(err, ErrGraphNeedsReview) {
|
||||
t.Fatal("unrelated valid relation allowed restriction omission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionEvidenceAndStrengthErrorsShareOneCorrection(t *testing.T) {
|
||||
badQuote := strings.Replace(graphRestrictionTestBlock, "〔", "(", 1)
|
||||
bad := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: "乙类", Type: "慎用于", Evidence: badQuote}}}
|
||||
model := &extractionResponseChat{response: graphCoverageResponse(t, bad)}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), graphRestrictionTestSource)
|
||||
if graph != nil || err == nil || model.calls != 2 || model.options.MaxTokens != 4096 || model.options.Temperature != 0 {
|
||||
t.Fatal("invalid response escaped bounded correction")
|
||||
}
|
||||
for _, message := range []string{err.Error(), model.messages[3].Content} {
|
||||
if !strings.Contains(message, "requires exact evidence") || !strings.Contains(message, "restriction strength mismatch") {
|
||||
t.Fatal("quote error hid independently anchored restriction strength")
|
||||
}
|
||||
}
|
||||
if bad.Relation[0].Evidence != badQuote || bad.Relation[0].Type != "慎用于" {
|
||||
t.Fatal("invalid model output was silently repaired")
|
||||
}
|
||||
if !strings.Contains(model.messages[0].Content, "A heading that governs a list supplies its predicate") ||
|
||||
strings.Contains(model.messages[0].Content, "section titles need not be included") {
|
||||
t.Fatal("persisted prompt still allows predicate headings to disappear")
|
||||
}
|
||||
if !strings.Contains(model.messages[0].Content, "literal OCR spelling in restriction targets, entity names and attributes") ||
|
||||
!strings.Contains(model.messages[0].Content, "Preserve supported usage procedures") ||
|
||||
!strings.Contains(model.messages[3].Content, "限制实体名称和属性也必须保留原文 OCR 字形") ||
|
||||
!strings.Contains(model.messages[3].Content, "保留有据的用量及使用步骤约束关系") {
|
||||
t.Fatal("correction omitted attribute grounding or usage preservation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionCorrectionMustExplicitlyPreserveCompleteSource(t *testing.T) {
|
||||
bad := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: "乙类", Type: "慎用于", Evidence: strings.Replace(graphRestrictionTestBlock, "〔", "(", 1)}}}
|
||||
good := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲品", Node2: graphRestrictionTestBlock, Type: "约束", Evidence: graphRestrictionTestBlock}}}
|
||||
model := &extractionResponseChat{responses: []*types.ChatResponse{graphCoverageResponse(t, bad), graphCoverageResponse(t, good)}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), graphRestrictionTestSource)
|
||||
if err != nil || graph == nil || model.calls != 2 || len(graph.Relation) != 1 ||
|
||||
graph.Relation[0].Type != "约束" || graph.Relation[0].Node2 != graphRestrictionTestBlock || graph.Relation[0].Evidence != graphRestrictionTestBlock {
|
||||
t.Fatal("explicit complete source constraint was not preserved by bounded correction")
|
||||
}
|
||||
good.Relation = append(good.Relation, &types.GraphRelation{Node1: "甲品", Node2: "乙类", Type: "慎用于", Evidence: graphRestrictionTestBlock})
|
||||
response := graphCoverageResponse(t, good)
|
||||
source := graphRestrictionTestSource
|
||||
if accepted, err := NewFormater().parseGraph(context.Background(), response.Content, &source); accepted != nil ||
|
||||
!errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), "restriction strength mismatch") {
|
||||
t.Fatal("full constraint concealed a contradictory typed edge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionQuotationMutationsStayRejected(t *testing.T) {
|
||||
const block = "〖 不适宜人群 〕 甲丿 IJ 类\n 、\t乙类"
|
||||
source := "本品\n" + block + "。"
|
||||
for _, quote := range []string{
|
||||
strings.Replace(block, "〖", "〔", 1),
|
||||
strings.Replace(block, "甲丿 IJ 类", "甲儿童类", 1),
|
||||
strings.ReplaceAll(block, " ", ""),
|
||||
strings.ReplaceAll(block, "\n", `\n`),
|
||||
strings.ReplaceAll(block, "\t", " "),
|
||||
} {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "本品", Node2: "乙类", Type: "不适用于", Evidence: quote}}}
|
||||
if err := validateGraphRelationEvidence(graph, source); err == nil || !strings.Contains(err.Error(), "requires exact evidence") {
|
||||
t.Fatal("mutated source quotation accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionScopeCannotCrossProducts(t *testing.T) {
|
||||
const first = "〔不适宜人群〕甲类、乙类"
|
||||
const second = "〔慎用人群〕甲类、乙类"
|
||||
source := "产品名称:甲品\n" + first + "。\n\n产品名称:乙品\n" + second + "。"
|
||||
for _, tc := range []struct {
|
||||
subject, quote, label string
|
||||
wantErr bool
|
||||
}{
|
||||
{"甲品", first, "不适用于", false},
|
||||
{"乙品", second, "慎用于", false},
|
||||
{"甲品", second, "慎用于", true},
|
||||
{"乙品", first, "不适用于", true},
|
||||
{"本品", "甲类", "慎用于", true},
|
||||
{"未出现产品", first, "不适用于", true},
|
||||
{"乙类", first, "不适用于", true},
|
||||
} {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: tc.subject, Node2: "甲类", Type: tc.label, Evidence: tc.quote}}}
|
||||
err := validateGraphRelationEvidence(graph, source)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("subject=%s, label=%s, error=%v", tc.subject, tc.label, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphRestrictionListsDoNotChangeQueryOrNarrativeExtraction(t *testing.T) {
|
||||
const source = "小说章节〔不适宜人群〕甲类、乙类。"
|
||||
if len(graphRestrictionScopes(source)) != 0 {
|
||||
t.Fatal("non-product narrative became a product restriction list")
|
||||
}
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: `[{"entity":"甲品"}]`, FinishReason: "stop"}}
|
||||
extractor := NewExtractor(model, &types.PromptTemplateStructured{Description: "Extract query entities"})
|
||||
graph, err := extractor.Extract(context.Background(), graphRestrictionTestSource)
|
||||
if err != nil || graph == nil || model.calls != 1 {
|
||||
t.Fatal("query extraction acquired persisted restriction coverage")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
const graphScopeSource = "不得用于预防疾病。\n〔 贮存方法 〗 避光 、 密封保存 。\n\n## 工作表1\n\n| 品牌名称 | 产品名称 | 终端零售价 |\n| --- | --- | --- |\n| 甲品牌 | 乙产品 | 139元/一瓶 |\n"
|
||||
|
||||
func TestGraphProductSourceSectionsReconstructExactInput(t *testing.T) {
|
||||
for _, source := range []string{graphScopeSource, "前段。\n\n产品名称:甲\n正文\n\n产品名称:乙\n另段", "1丨甲\n内容\n2丨乙\n内容"} {
|
||||
var plan struct {
|
||||
Sections []struct {
|
||||
Number int `json:"section"`
|
||||
Text string `json:"text"`
|
||||
} `json:"source_sections"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(graphProductSourcePrompt(source)), &plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var reconstructed strings.Builder
|
||||
for i, section := range plan.Sections {
|
||||
if section.Number != i+1 {
|
||||
t.Fatal("nonsequential scope")
|
||||
}
|
||||
reconstructed.WriteString(section.Text)
|
||||
}
|
||||
if len(plan.Sections) < 2 || reconstructed.String() != source {
|
||||
t.Fatal("source changed or omitted")
|
||||
}
|
||||
}
|
||||
const ordinary = "Alice knows Bob."
|
||||
if graphProductSourcePrompt(ordinary) != ordinary {
|
||||
t.Fatal("ordinary source was rewritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphScopedCorrectionKeepsWarningsStorageAndPrices(t *testing.T) {
|
||||
response := []map[string]any{
|
||||
{"entity1": "乙产品", "entity2": "预防疾病", "relation": "不适用于", "evidence": "不得用于预防疾病"},
|
||||
{"entity1": "乙产品", "entity2": "避光密封保存", "relation": "约束", "evidence": "避光 、 密封保存"},
|
||||
{"entity": "甲品牌乙产品", "entity_attributes": []string{"终端零售价:139元/一瓶"}},
|
||||
}
|
||||
bad, _ := json.Marshal(response)
|
||||
response[0]["entity1"], response[1]["entity1"] = "本品", "本品"
|
||||
good, _ := json.Marshal(response)
|
||||
model := &extractionResponseChat{responses: []*types.ChatResponse{
|
||||
{Content: string(bad), FinishReason: "stop"}, {Content: string(good), FinishReason: "stop"},
|
||||
}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), graphScopeSource)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 2 || model.calls != 2 {
|
||||
t.Fatalf("bounded correction failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(model.messages[1].Content, `"source_sections"`) ||
|
||||
!strings.Contains(model.messages[3].Content, "evidence is in source sections [1]") ||
|
||||
!strings.Contains(model.messages[3].Content, "proposed subject occurs in sections [3]") {
|
||||
t.Fatal("correction did not identify the conflicting source scopes")
|
||||
}
|
||||
for _, relation := range graph.Relation {
|
||||
if relation.Node1 != "本品" || !strings.Contains(graphScopeSource, relation.Evidence) {
|
||||
t.Fatal("scope/evidence changed")
|
||||
}
|
||||
}
|
||||
if graph.Node[len(graph.Node)-1] == nil {
|
||||
t.Fatal("lost graph node")
|
||||
}
|
||||
priceFound := false
|
||||
for _, node := range graph.Node {
|
||||
for _, attribute := range node.Attributes {
|
||||
priceFound = priceFound || attribute == "终端零售价:139元/一瓶"
|
||||
}
|
||||
}
|
||||
if !priceFound {
|
||||
t.Fatal("correction dropped the supported table price")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphSourceEnvelopeCannotBecomeEvidence(t *testing.T) {
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: `[{"entity1":"本品","entity2":"预防疾病","relation":"不适用于","evidence":"source_sections"}]`, FinishReason: "stop"}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
if graph, err := extractor.Extract(context.Background(), graphScopeSource); err == nil || graph != nil || model.calls != 2 {
|
||||
t.Fatal("metadata was accepted as evidence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphSeriesHeadingCannotPromoteLocalWarnings(t *testing.T) {
|
||||
for _, title := range []string{"## 甲乙套盒系列介绍", "甲乙套盒系列"} {
|
||||
source := title + "\n\n过敏者慎甩;粘膜破损处禁用。\n本品不是药品。"
|
||||
bad := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲乙套盒系列", Node2: "过敏者", Type: "慎用于", Evidence: "过敏者慎甩"}}}
|
||||
if err := validateGraphRelationEvidence(bad, source); err == nil || !strings.Contains(err.Error(), "series heading") {
|
||||
t.Fatal("series title used as local product")
|
||||
}
|
||||
bad.Relation[0].Node1 = "本品"
|
||||
if err := validateGraphRelationEvidence(bad, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
const explicit = "## 甲乙套盒系列\n\n甲乙套盒系列过敏者慎用。"
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "甲乙套盒系列", Node2: "过敏者", Type: "慎用于", Evidence: "甲乙套盒系列过敏者慎用"}}}
|
||||
if err := validateGraphRelationEvidence(graph, explicit); err != nil {
|
||||
t.Fatal("explicit series rule rejected", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestGraphComparisonPreservesSourceAndOrdinaryNegation(t *testing.T) {
|
||||
for _, tc := range []struct{ source, comparison string }{
|
||||
{"不不建议使用", "不建议使用"},
|
||||
{"不能使用", "不能使用"},
|
||||
{"不不能使用", "不不能使用"},
|
||||
{"不不建议使用", "不不建议使用"},
|
||||
{"开塞露露不不能⻓长期用", "开塞露露不能⻓长期用"},
|
||||
} {
|
||||
if got := graphComparisonText(tc.source); got != tc.comparison {
|
||||
t.Errorf("comparison of %q = %q, want %q", tc.source, got, tc.comparison)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphProductSectionHeadingCannotOwnStorageRule(t *testing.T) {
|
||||
const source = "本品不是药品。〔 储存方法 〗 储存于阴凉干燥处 , 避光 、 隔热 、 密封保存 。"
|
||||
for _, tc := range []struct {
|
||||
name, subject, evidence string
|
||||
wantError bool
|
||||
}{
|
||||
{"section without quoted heading", "储存方法", "储存于阴凉干燥处 , 避光 、 隔热 、 密封保存", true},
|
||||
{"section included in evidence", "储存方法", "〔 储存方法 〗 储存于阴凉干燥处 , 避光 、 隔热 、 密封保存", true},
|
||||
{"local product owns rule", "本品", "储存于阴凉干燥处 , 避光 、 隔热 、 密封保存", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: tc.subject, Node2: "储存于阴凉干燥处,避光、隔热、密封保存", Type: "约束", Evidence: tc.evidence}}}
|
||||
err := validateGraphRelationEvidenceItem(graph, source)
|
||||
if tc.wantError {
|
||||
if err == nil || !strings.Contains(err.Error(), "product section heading") {
|
||||
t.Fatalf("section incorrectly owns product rule: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "储存方法", Node2: "温度与湿度要求", Type: "包含", Evidence: "储存方法包含温度与湿度要求"}}}
|
||||
if err := validateGraphRelationEvidenceItem(graph, "教材的储存方法包含温度与湿度要求。"); err != nil {
|
||||
t.Fatalf("ordinary named concept rejected: %v", err)
|
||||
}
|
||||
for _, evidence := range []string{"注意事项要求记录审核日期", "〔 注意事项 〗 注意事项要求记录审核日期"} {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: "注意事项", Node2: "记录审核日期", Type: "约束", Evidence: evidence}}}
|
||||
if err := validateGraphRelationEvidenceItem(graph, "〔 注意事项 〗 注意事项要求记录审核日期。"); err != nil {
|
||||
t.Fatalf("explicit body subject rejected: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphGenericAndCombinedUseEvidence(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, source, from, to, label, quote, wantErr string
|
||||
}{
|
||||
{
|
||||
name: "OCR prohibition preserves original evidence", source: "本品不不能用于破损皮肤。", from: "本品", to: "破损皮肤", label: "禁用于", quote: "本品不不能用于破损皮肤",
|
||||
},
|
||||
{
|
||||
name: "OCR negative advice remains recognized", source: "不不建议使用番泻叶。", from: "番泻叶", to: "使用", label: "不适用于", quote: "不不建议使用番泻叶",
|
||||
},
|
||||
{
|
||||
name: "OCR prohibition cannot become permission", source: "本品不不能用于破损皮肤。", from: "本品", to: "破损皮肤", label: "适用于", quote: "用于破损皮肤", wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "OCR comparison never repairs quotation", source: "本品不不能用于破损皮肤。", from: "本品", to: "破损皮肤", label: "禁用于", quote: "本品不能用于破损皮肤", wantErr: "requires exact evidence",
|
||||
},
|
||||
{
|
||||
name: "avoid combined use", source: "避免与拮抗药物同用。", from: "本品", to: "与拮抗药物同用", label: "禁用于", quote: "避免与拮抗药物同用",
|
||||
},
|
||||
{
|
||||
name: "avoid combined use with alternate conjunction", source: "避免和其他产品合用。", from: "本品", to: "避免和其他产品合用", label: "约束", quote: "避免和其他产品合用",
|
||||
},
|
||||
{
|
||||
name: "avoid simultaneous use", source: "避免与其他产品同时使用。", from: "本品", to: "与其他产品同时使用", label: "不适用于", quote: "避免与其他产品同时使用",
|
||||
},
|
||||
{
|
||||
name: "combined operation cannot disappear", source: "避免与拮抗药物同用。", from: "本品", to: "拮抗药物", label: "禁用于", quote: "避免与拮抗药物同用", wantErr: "omits the combined-use scope",
|
||||
},
|
||||
{
|
||||
name: "avoid combined use cannot become permission", source: "避免和其他产品同时使用。", from: "本品", to: "和其他产品同时使用", label: "适用于", quote: "避免和其他产品同时使用", wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "avoid benefit is not a use prohibition", source: "本品可避免皮肤不适。", from: "本品", to: "皮肤不适", label: "禁用于", quote: "本品可避免皮肤不适", wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "identity cannot borrow effect denial", source: "本品不是药品,不具有治疗、护理、保健作用。", from: "本品", to: "药品", label: "不适用于", quote: "本品不是药品,不具有治疗、护理、保健作用", wantErr: "identity disclaimer",
|
||||
},
|
||||
{
|
||||
name: "identity alone does not restrict use", source: "本品不是药品。", from: "本品", to: "药品", label: "不适用于", quote: "本品不是药品", wantErr: "identity disclaimer",
|
||||
},
|
||||
{
|
||||
name: "effect disclaimer remains supported", source: "本品不是药品,不具有治疗作用。", from: "本品", to: "治疗作用", label: "不支持", quote: "本品不具有治疗作用", wantErr: "requires exact evidence",
|
||||
},
|
||||
{
|
||||
name: "separate effect denial remains valid", source: "本品不是药品,不具有治疗作用。", from: "本品", to: "治疗作用", label: "不支持", quote: "不具有治疗作用",
|
||||
},
|
||||
{
|
||||
name: "state prediction is not a use restriction", source: "第一次会议不会出现投票。", from: "第一次会议", to: "投票", label: "不适用于", quote: "第一次会议不会出现投票", wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "chapter heading is not the actor", source: "## 沟通指南\n\n明确目标支持积极建议。", from: "沟通指南", to: "积极建议", label: "支持", quote: "明确目标支持积极建议", wantErr: "source heading",
|
||||
},
|
||||
{
|
||||
name: "actual named book can support practice", source: "## 沟通指南\n\n沟通指南支持沟通练习。", from: "沟通指南", to: "沟通练习", label: "支持", quote: "沟通指南支持沟通练习",
|
||||
},
|
||||
{
|
||||
name: "uncertainty cannot be omitted", source: "可能是降价导致利润下降。", from: "降价", to: "利润下降", label: "导致", quote: "降价导致利润下降", wantErr: "omits source attribution, time or uncertainty",
|
||||
},
|
||||
{
|
||||
name: "uncertainty preserved in scoped association", source: "降价可能导致利润下降。", from: "降价", to: "利润下降", label: "关联", quote: "降价可能导致利润下降",
|
||||
},
|
||||
{
|
||||
name: "uncertainty in evidence alone cannot make causal edge certain", source: "降价可能导致利润下降。", from: "降价", to: "利润下降", label: "导致", quote: "降价可能导致利润下降", wantErr: "turns source possibility",
|
||||
},
|
||||
{
|
||||
name: "uncertainty scoped in causal target", source: "降价可能导致利润下降。", from: "降价", to: "可能的利润下降", label: "导致", quote: "降价可能导致利润下降",
|
||||
},
|
||||
{
|
||||
name: "explicit certainty does not need uncertainty scope", source: "降价导致利润下降。", from: "降价", to: "利润下降", label: "导致", quote: "降价导致利润下降",
|
||||
},
|
||||
{
|
||||
name: "historical view cannot become general truth", source: "以前我认为,工作支持自我肯定。", from: "工作", to: "自我肯定", label: "支持", quote: "以前我认为,工作支持自我肯定", wantErr: "attributed opinion",
|
||||
},
|
||||
{
|
||||
name: "historical view remains attributed", source: "以前我认为,工作支持自我肯定。", from: "叙述者以前的观点", to: "工作支持自我肯定", label: "关联", quote: "以前我认为,工作支持自我肯定",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw, err := json.Marshal([]map[string]string{{"entity1": tc.from, "entity2": tc.to, "relation": tc.label, "evidence": tc.quote}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: string(raw), FinishReason: "stop"}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), tc.source)
|
||||
if tc.wantErr != "" {
|
||||
if graph != nil || err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("graph=%+v, err=%v, want %q", graph, err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || graph == nil || len(graph.Relation) != 1 || graph.Relation[0].Evidence != tc.quote {
|
||||
t.Fatalf("supported source quotation was rejected or changed: %+v, %v", graph, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphEmptyCoverageRequiresReview(t *testing.T) {
|
||||
for _, response := range []string{"[]", `[{"entity":"标题","entity_attributes":["没有关系"]}]`} {
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: response, FinishReason: "stop"}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), "标题,没有关系。")
|
||||
if graph != nil || !errors.Is(err, ErrGraphNeedsReview) || !strings.Contains(err.Error(), "graph_needs_review") || model.calls != 2 {
|
||||
t.Fatalf("zero-edge coverage must require review after one correction: graph=%+v, err=%v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphCoverageCorrectionCanRestoreSupportedRelationship(t *testing.T) {
|
||||
const source = "基础概念是实践练习的先修条件。"
|
||||
model := &extractionResponseChat{responses: []*types.ChatResponse{
|
||||
{Content: `[{"entity":"基础概念"},{"entity":"实践练习"}]`, FinishReason: "stop"},
|
||||
{Content: `[{"entity1":"基础概念","entity2":"实践练习","relation":"先修条件","evidence":"基础概念是实践练习的先修条件。"}]`, FinishReason: "stop"},
|
||||
}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 1 || model.calls != 2 {
|
||||
t.Fatalf("supported correction failed: %+v, %v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
for _, instruction := range []string{"actual subject", "Product-use rules", "speaker, time and uncertainty", "不是药品", "Empty or entity-only results require coverage review"} {
|
||||
if !strings.Contains(model.messages[0].Content, instruction) {
|
||||
t.Errorf("graph prompt lacks %q", instruction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphProductBoundaryAndConstraintRegressions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, source, from, to, label, quote, wantErr string
|
||||
}{
|
||||
{name: "OCR spaced numbered product", source: "本品不应用于疾病预防。\n\n02 丨 NMN 寻幽紧爱修护套 ( 紧致凝胶 )\n产品成分:甲。", from: "NMN寻幽紧爱修护套(紧致凝胶)", to: "疾病预防", label: "不适用于", quote: "本品不应用于疾病预防", wantErr: "earlier unnamed product"},
|
||||
{name: "brand and name in separate table columns", source: "不得用于疾病预防。\n\n## 工作表1\n\n| 品牌名称 | 产品名称 | 功效 |\n| --- | --- | --- |\n| 玉丹 | 精华油 | 保湿 |", from: "玉丹精华油", to: "疾病预防", label: "不适用于", quote: "不得用于疾病预防", wantErr: "earlier unnamed product"},
|
||||
{name: "same table row attribution retained", source: "不得用于疾病预防。\n\n| 品牌名称 | 产品名称 | 功效 |\n| --- | --- | --- |\n| 玉丹 | 精华油 | 保湿 |", from: "玉丹精华油", to: "保湿", label: "关联", quote: "| 玉丹 | 精华油 | 保湿 |"},
|
||||
{name: "constraint retains prohibition", source: "避免与拮抗药物同用。", from: "本品", to: "拮抗药物同用", label: "约束", quote: "避免与拮抗药物同用", wantErr: "omits the negation"},
|
||||
{name: "constraint preserves prohibition", source: "避免与拮抗药物同用。", from: "本品", to: "避免与拮抗药物同用", label: "约束", quote: "避免与拮抗药物同用"},
|
||||
{name: "stop action cannot disappear", source: "使用过程中,如有不适,请立即停止使用。", from: "使用过程中如有不适", to: "使用", label: "约束", quote: "使用过程中,如有不适,请立即停止使用", wantErr: "omits the stop-use action"},
|
||||
{name: "stop condition cannot disappear", source: "如有不适,请立即停止使用。", from: "本品", to: "立即停止使用", label: "约束", quote: "如有不适,请立即停止使用", wantErr: "omits the stop-use condition"},
|
||||
{name: "storage is not prohibition", source: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒。", from: "本品", to: "阳光直晒", label: "禁用于", quote: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒", wantErr: "misclassifies a storage constraint"},
|
||||
{name: "storage negation cannot disappear", source: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒。", from: "本品", to: "阳光直晒", label: "约束", quote: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒", wantErr: "omits the storage action"},
|
||||
{name: "storage action retained", source: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒。", from: "本品", to: "阴凉干燥处存放并避免阳光直晒", label: "约束", quote: "〔贮存方法〕请置于阴凉干燥处,避免阳光直晒"},
|
||||
{name: "storage metadata is not the requirement", source: "贮存方法:避光密封保存。", from: "本品", to: "贮存条件", label: "约束", quote: "贮存方法:避光密封保存", wantErr: "omits the storage action"},
|
||||
{name: "child access restriction cannot disappear", source: "请放置于儿童不易触及之处。", from: "本品", to: "儿童触及", label: "约束", quote: "请放置于儿童不易触及之处", wantErr: "storage action"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: tc.from, Node2: tc.to, Type: tc.label, Evidence: tc.quote}}}
|
||||
err := validateGraphRelationEvidence(graph, tc.source)
|
||||
if tc.wantErr == "" && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) {
|
||||
t.Fatalf("got %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphProductPronounsCannotMergeAcrossSections(t *testing.T) {
|
||||
const source = "请将本品置于阴凉干燥处存放。\n\n〔 产品名称 〕白番茄饮品\n〔 贮存方法 〕请置于阴凉处保存。"
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{
|
||||
{Node1: "本品", Node2: "阴凉干燥处存放", Type: "约束", Evidence: "请将本品置于阴凉干燥处存放"},
|
||||
{Node1: "本品", Node2: "阴凉处保存", Type: "约束", Evidence: "请置于阴凉处保存"},
|
||||
}}
|
||||
if err := validateGraphRelationEvidence(graph, source); err == nil || !strings.Contains(err.Error(), "merges an unnamed product") {
|
||||
t.Fatalf("ambiguous merged referent accepted: %v", err)
|
||||
}
|
||||
graph.Relation[1].Node1 = "白番茄饮品"
|
||||
if err := validateGraphRelationEvidence(graph, source); err != nil {
|
||||
t.Fatalf("distinct products should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphCorrectionReceivesMultipleFailures(t *testing.T) {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{
|
||||
{Node1: "本品", Node2: "其他产品合用", Type: "约束", Evidence: "避免与其他产品合用"},
|
||||
{Node1: "本品", Node2: "使用", Type: "约束", Evidence: "如有不适立即停止使用"},
|
||||
}}
|
||||
err := validateGraphRelationEvidence(graph, "避免与其他产品合用。如有不适立即停止使用。")
|
||||
if err == nil || !strings.Contains(err.Error(), "omits the negation") || !strings.Contains(err.Error(), "omits the stop-use action") {
|
||||
t.Fatalf("correction must expose both failures: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphExplicitRelationDirections(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, from, to, label, quote string
|
||||
reversed bool
|
||||
}{
|
||||
{"part cannot contain whole", "交流", "会议", "包含", "交流是会议的重要内容", true},
|
||||
{"whole contains part", "会议", "交流", "包含", "交流是会议的重要内容", false},
|
||||
{"literal whole and part reversed", "交流", "会议", "包含", "会议包含交流", true},
|
||||
{"literal whole and part retained", "会议", "交流", "包含", "会议包含交流", false},
|
||||
{"goal cannot precede prerequisite", "实践练习", "基础概念", "先修条件", "基础概念是实践练习的先修条件", true},
|
||||
{"prerequisite precedes goal", "基础概念", "实践练习", "先修条件", "基础概念是实践练习的先修条件", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{Node1: tc.from, Node2: tc.to, Type: tc.label, Evidence: tc.quote}}}
|
||||
err := validateGraphRelationEvidence(graph, tc.quote)
|
||||
if tc.reversed && (err == nil || !strings.Contains(err.Error(), "reverses the explicitly stated relation direction")) {
|
||||
t.Fatalf("reversed direction passed: %v", err)
|
||||
}
|
||||
if !tc.reversed && err != nil {
|
||||
t.Fatalf("valid direction rejected: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphUsageProcedureCannotTreatProductAsPrerequisiteGoal(t *testing.T) {
|
||||
const source = "使用本品前先清洗双手,然后取本品涂于手部。"
|
||||
for _, target := range []string{"本品", "本产品", "该产品", "本品(未命名)"} {
|
||||
graph := &types.GraphData{Relation: []*types.GraphRelation{{
|
||||
Node1: "先清洗双手,然后取本品涂于手部", Node2: target,
|
||||
Type: "先修条件", Evidence: source,
|
||||
}}}
|
||||
if err := validateGraphRelationEvidence(graph, source); err == nil || !strings.Contains(err.Error(), "product itself as a prerequisite goal") {
|
||||
t.Fatalf("invalid product prerequisite accepted for %q: %v", target, err)
|
||||
}
|
||||
}
|
||||
for _, relation := range []*types.GraphRelation{
|
||||
{Node1: "清洗双手", Node2: "取本品涂于手部", Type: "先修条件", Evidence: source},
|
||||
{Node1: "本品", Node2: "先清洗双手,然后取本品涂于手部", Type: "约束", Evidence: source},
|
||||
} {
|
||||
if err := validateGraphRelationEvidence(&types.GraphData{Relation: []*types.GraphRelation{relation}}, source); err != nil {
|
||||
t.Fatalf("supported action relation rejected: %+v: %v", relation, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,818 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
type extractionResponseChat struct {
|
||||
response *types.ChatResponse
|
||||
responses []*types.ChatResponse
|
||||
calls int
|
||||
options *chat.ChatOptions
|
||||
messages []chat.Message
|
||||
}
|
||||
|
||||
func (m *extractionResponseChat) Chat(_ context.Context, messages []chat.Message, options *chat.ChatOptions) (*types.ChatResponse, error) {
|
||||
m.options = options
|
||||
m.messages = messages
|
||||
m.calls++
|
||||
if len(m.responses) >= m.calls {
|
||||
return m.responses[m.calls-1], nil
|
||||
}
|
||||
return m.response, nil
|
||||
}
|
||||
|
||||
func (*extractionResponseChat) ChatStream(context.Context, []chat.Message, *chat.ChatOptions) (<-chan types.StreamResponse, error) {
|
||||
panic("unexpected streaming extraction")
|
||||
}
|
||||
|
||||
func (*extractionResponseChat) GetModelName() string { return "extraction-test" }
|
||||
func (*extractionResponseChat) GetModelID() string { return "extraction-test" }
|
||||
|
||||
func TestExtractorRequiresCompletedGraphContent(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
response *types.ChatResponse
|
||||
wantErr string
|
||||
}{
|
||||
{name: "missing response", wantErr: "no response"},
|
||||
{
|
||||
name: "reasoning exhausted the token budget",
|
||||
response: &types.ChatResponse{
|
||||
FinishReason: "length", ReasoningContent: "private reasoning",
|
||||
Usage: types.TokenUsage{CompletionTokens: 4096},
|
||||
},
|
||||
wantErr: "exceeded the output token limit",
|
||||
},
|
||||
{
|
||||
name: "valid JSON cannot hide token exhaustion",
|
||||
response: &types.ChatResponse{
|
||||
Content: `[{"entity":"Alice"}]`, FinishReason: "length",
|
||||
},
|
||||
wantErr: "exceeded the output token limit",
|
||||
},
|
||||
{
|
||||
name: "reasoning is not a graph response",
|
||||
response: &types.ChatResponse{
|
||||
Content: " \n\t", ReasoningContent: `[{"entity":"private reasoning"}]`, FinishReason: "stop",
|
||||
},
|
||||
wantErr: "empty content",
|
||||
},
|
||||
{
|
||||
name: "explicit empty result succeeds",
|
||||
response: &types.ChatResponse{Content: `[]`, FinishReason: "stop"},
|
||||
},
|
||||
{
|
||||
name: "unknown object is not an empty result",
|
||||
response: &types.ChatResponse{Content: `{"unexpected":true}`, FinishReason: "stop"},
|
||||
wantErr: "no recognized graph extractions",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
model := &extractionResponseChat{response: tc.response}
|
||||
extractor := NewExtractor(model, &types.PromptTemplateStructured{Description: "Extract entities"})
|
||||
graph, err := extractor.Extract(context.Background(), "Alice knows Bob")
|
||||
if model.options == nil || model.options.Thinking == nil || *model.options.Thinking {
|
||||
t.Fatal("entity extraction must explicitly disable thinking")
|
||||
}
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
if strings.Contains(err.Error(), "private reasoning") {
|
||||
t.Fatal("error must not expose reasoning content")
|
||||
}
|
||||
if graph != nil {
|
||||
t.Fatalf("failed extraction returned graph: %+v", graph)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || graph == nil || len(graph.Node) != 0 || len(graph.Relation) != 0 {
|
||||
t.Fatalf("explicit empty result = %+v, %v", graph, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphExtractorRequiresSourceEvidence(t *testing.T) {
|
||||
// These contraindications are from a production product-information chunk,
|
||||
// including its OCR typo; the failed extraction claimed positive suitability.
|
||||
const productWarnings = "私蜜妍+蜜桃初醒套盒系列\n3 . 过敏者慎甩 4 . 粘膜破损处禁用 ;\n5 . 不得用于性生活中对性病的预防 。\n〖 警示说明 〗 本品不是药品 , 不具有治疗 、 护理 、 保健作用 。"
|
||||
for _, tc := range []struct {
|
||||
name, content, from, to, relation, evidence, wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing evidence", content: "本品适用于成人", from: "本品", to: "成人", relation: "适用于",
|
||||
wantErr: "requires exact evidence",
|
||||
},
|
||||
{
|
||||
name: "invented evidence", content: "本品适用于成人", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于儿童",
|
||||
wantErr: "requires exact evidence",
|
||||
},
|
||||
{
|
||||
name: "whitespace is part of source", content: "本品 适用于成人", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于成人",
|
||||
wantErr: "requires exact evidence",
|
||||
},
|
||||
{
|
||||
name: "exact positive evidence", content: "本品适用于成人", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于成人",
|
||||
},
|
||||
{
|
||||
name: "real allergy warning", content: productWarnings, from: "私蜜妍+蜜桃初醒套盒系列", to: "过敏者", relation: "适用于", evidence: "过敏者慎甩",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "real allergy warning reversed", content: productWarnings, from: "过敏者", to: "本品", relation: "适用于", evidence: "过敏者慎甩",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "real damaged mucosa warning", content: productWarnings, from: "私蜜妍+蜜桃初醒套盒系列", to: "粘膜破损处", relation: "适用于", evidence: "粘膜破损处禁用",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "real STI prevention warning", content: productWarnings, from: "私蜜妍+蜜桃初醒套盒系列", to: "性生活中对性病的预防", relation: "适用于", evidence: "不得用于性生活中对性病的预防",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "quote cannot hide preceding negation", content: productWarnings, from: "私蜜妍+蜜桃初醒套盒系列", to: "性生活中对性病的预防", relation: "用于", evidence: "用于性生活中对性病的预防",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "target alias cannot hide preceding negation", content: "本品不得用于性病预防。", from: "本品", to: "预防性病", relation: "用于", evidence: "用于性病预防",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "real disclaimer list", content: productWarnings, from: "本品", to: "护理", relation: "具有", evidence: "不具有治疗 、 护理 、 保健作用",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "comma separated negative list", content: "本品不具有治疗,护理,保健作用。", from: "本品", to: "护理", relation: "具有", evidence: "护理",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "later comma separated negative list item", content: "本品不具有治疗,护理,保健作用。", from: "本品", to: "保健作用", relation: "具有", evidence: "保健作用",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "multiple negative clauses", content: "本品不得用于性病预防,也不具有治疗作用。", from: "本品", to: "治疗作用", relation: "具有", evidence: "治疗作用",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "accurate caution preserved", content: productWarnings, from: "本品", to: "过敏者", relation: "慎用于", evidence: "过敏者慎甩",
|
||||
},
|
||||
{
|
||||
name: "accurate prohibition preserved", content: productWarnings, from: "本品", to: "粘膜破损处", relation: "禁用于", evidence: "粘膜破损处禁用",
|
||||
},
|
||||
{
|
||||
name: "accurate non applicability preserved", content: productWarnings, from: "本品", to: "性生活中对性病的预防", relation: "不适用于", evidence: "不得用于性生活中对性病的预防",
|
||||
},
|
||||
{
|
||||
name: "avoid contact prohibition preserved", content: "〔 注意事项 〗 避免接触眼睛 , 若产品不慎入眼 , 请立即用清水冲洗 。", from: "本品", to: "眼睛", relation: "禁用于", evidence: "避免接触眼睛",
|
||||
},
|
||||
{
|
||||
name: "avoid contact restriction with exposure alias", content: "〔 注意事项 〗 避免接触眼睛 , 若产品不慎入眼 , 请立即用清水冲洗 , 如有不适 , 请立即停止使用。", from: "本品", to: "入眼", relation: "禁用于", evidence: "避免接触眼睛 , 若产品不慎入眼 , 请立即用清水冲洗 , 如有不适 , 请立即停止使用",
|
||||
},
|
||||
{
|
||||
name: "avoid contact is not positive use", content: "本品避免接触眼睛。", from: "本品", to: "眼睛", relation: "适用于", evidence: "本品避免接触眼睛",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "avoid contact is not conditional permission", content: "本品避免接触眼睛。", from: "本品", to: "眼睛", relation: "条件适用于", evidence: "本品避免接触眼睛",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "avoid contact is not caution", content: "本品避免接触眼睛。", from: "本品", to: "眼睛", relation: "慎用于", evidence: "本品避免接触眼睛",
|
||||
wantErr: "weakens a source prohibition",
|
||||
},
|
||||
{
|
||||
name: "benefit avoiding discomfort is not prohibition", content: "本品帮助避免干燥引起的不适。", from: "本品", to: "干燥引起的不适", relation: "禁用于", evidence: "本品帮助避免干燥引起的不适",
|
||||
wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "damaged OCR restriction remains unresolved", content: "避女性三期僮生 ( 羟期 . 孕期飞产期 } 。", from: "本品", to: "女性三期(羟期.孕期飞产期)", relation: "禁用于", evidence: "避女性三期僮生 ( 羟期 . 孕期飞产期 }",
|
||||
wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "accidental exposure response is not suitability", content: "若不慎入眼 , 请立即用清水冲洗 , 严重者立即就医。", from: "本品", to: "眼部接触", relation: "条件适用于", evidence: "若不慎入眼 , 请立即用清水冲洗 , 严重者立即就医",
|
||||
wantErr: "without an explicit source use claim",
|
||||
},
|
||||
{
|
||||
name: "accidental exposure response remains a constraint", content: "若不慎入眼 , 请立即用清水冲洗 , 严重者立即就医。", from: "本品", to: "不慎入眼后立即用清水冲洗", relation: "约束", evidence: "若不慎入眼 , 请立即用清水冲洗 , 严重者立即就医",
|
||||
},
|
||||
{
|
||||
name: "unrelated warning in same sentence", content: "本品适用于成人,过敏者禁用。", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于成人,过敏者禁用",
|
||||
},
|
||||
{
|
||||
name: "unrelated warning before positive claim", content: "过敏者禁用,但本品适用于成人。", from: "本品", to: "成人", relation: "适用于", evidence: "过敏者禁用,但本品适用于成人",
|
||||
},
|
||||
{
|
||||
name: "unrelated condition before positive claim", content: "如果不适请停用,本品适用于成人。", from: "本品", to: "成人", relation: "适用于", evidence: "如果不适请停用,本品适用于成人",
|
||||
},
|
||||
{
|
||||
name: "unrelated disclaimer in previous sentence", content: "本品不是药品,不具有治疗作用。本品适用于成人日常清洁。", from: "本品", to: "成人日常清洁", relation: "适用于", evidence: "本品适用于成人日常清洁",
|
||||
},
|
||||
{
|
||||
name: "OCR sections without punctuation", content: "〖 使用范围 〗 用于阴道黏膜抑菌 〔 注意事项 〗 1. 不得用于治疗疾病或者改善症状 ;", from: "本品", to: "阴道黏膜", relation: "适用于", evidence: "用于阴道黏膜抑菌",
|
||||
},
|
||||
{
|
||||
name: "restriction inside OCR section remains enforced", content: "〖 使用范围 〗 用于阴道黏膜抑菌 〔 注意事项 〗 1. 不得用于治疗疾病或者改善症状 ;", from: "本品", to: "治疗疾病", relation: "适用于", evidence: "用于治疗疾病或者改善症状",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "should not be used restriction", content: "本品不应用于性生活中对性病的预防;", from: "本品", to: "性生活中对性病的预防", relation: "适用于", evidence: "用于性生活中对性病的预防",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "unrelated negative adjective", content: "本品用于改善不良习惯。", from: "本品", to: "不良习惯", relation: "改善", evidence: "本品用于改善不良习惯",
|
||||
},
|
||||
{
|
||||
name: "conditional suitability cannot become unconditional", content: "仅在医师指导下,本品适用于成人。", from: "本品", to: "成人", relation: "适用于", evidence: "本品适用于成人",
|
||||
wantErr: "conditional source claim",
|
||||
},
|
||||
{
|
||||
name: "inline conditional suitability", content: "本品仅限在医师指导下适用于成人。", from: "本品", to: "成人", relation: "适用于", evidence: "本品仅限在医师指导下适用于成人",
|
||||
wantErr: "conditional source claim",
|
||||
},
|
||||
{
|
||||
name: "conditional label preserved", content: "仅在医师指导下,本品适用于成人。", from: "本品", to: "成人", relation: "条件适用于", evidence: "仅在医师指导下,本品适用于成人",
|
||||
},
|
||||
{
|
||||
name: "conditional use requires a real condition", content: "本品适用于成人。", from: "本品", to: "成人", relation: "条件适用于", evidence: "本品适用于成人",
|
||||
wantErr: "without an explicit condition",
|
||||
},
|
||||
{
|
||||
name: "conditional use evidence retains the condition", content: "仅在医师指导下,本品适用于成人。", from: "本品", to: "成人", relation: "条件适用于", evidence: "本品适用于成人",
|
||||
wantErr: "without an explicit condition",
|
||||
},
|
||||
{
|
||||
name: "conditional use cannot substitute a condition for its use target", content: "初次使用本品者先在手臂内侧试用 , 无过敏现象再使用。", from: "本品", to: "过敏现象", relation: "条件适用于", evidence: "初次使用本品者先在手臂内侧试用 , 无过敏现象再使用",
|
||||
wantErr: "without a source-grounded use target",
|
||||
},
|
||||
{
|
||||
name: "conditional procedure retains source use target", content: "清洗外阴后 , 将凝胶推入阴道。", from: "凝胶", to: "阴道", relation: "条件适用于", evidence: "清洗外阴后 , 将凝胶推入阴道",
|
||||
},
|
||||
{
|
||||
name: "ordinary causal condition is not product suitability", content: "如果持续降价,降价导致利润下降。", from: "降价", to: "利润下降", relation: "导致", evidence: "如果持续降价,降价导致利润下降",
|
||||
},
|
||||
{
|
||||
name: "ordinary support condition is not product suitability", content: "只有认真倾听,倾听支持成交。", from: "倾听", to: "成交", relation: "支持", evidence: "只有认真倾听,倾听支持成交",
|
||||
},
|
||||
{
|
||||
name: "ordinary conditional evidence cannot omit condition", content: "只有认真倾听,倾听支持成交。", from: "倾听", to: "成交", relation: "支持", evidence: "倾听支持成交",
|
||||
wantErr: "omits a source condition",
|
||||
},
|
||||
{
|
||||
name: "unrelated warning before general support", content: "不得强行推销,倾听支持成交。", from: "倾听", to: "成交", relation: "支持", evidence: "倾听支持成交",
|
||||
},
|
||||
{
|
||||
name: "explicit negative causation", content: "降价不导致客户忠诚。", from: "降价", to: "客户忠诚", relation: "不导致", evidence: "降价不导致客户忠诚",
|
||||
},
|
||||
{
|
||||
name: "explicit future negative causation", content: "降价不会导致客户忠诚。", from: "降价", to: "客户忠诚", relation: "不导致", evidence: "降价不会导致客户忠诚",
|
||||
},
|
||||
{
|
||||
name: "explicit negative support", content: "强行推销不支持客户信任。", from: "强行推销", to: "客户信任", relation: "不支持", evidence: "强行推销不支持客户信任",
|
||||
},
|
||||
{
|
||||
name: "negative causation cannot be positive", content: "降价不导致客户忠诚。", from: "降价", to: "客户忠诚", relation: "导致", evidence: "降价不导致客户忠诚",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "negative support cannot be positive", content: "强行推销不支持客户信任。", from: "强行推销", to: "客户信任", relation: "支持", evidence: "强行推销不支持客户信任",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "use prohibition cannot imply negative causation", content: "本品不得用于疾病治疗。", from: "本品", to: "疾病治疗", relation: "不导致", evidence: "本品不得用于疾病治疗",
|
||||
wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "combined use cannot become blanket ingredient prohibition", content: "本品不得与阴离子成分同时使用。", from: "本品", to: "阴离子成分", relation: "禁用于", evidence: "本品不得与阴离子成分同时使用",
|
||||
wantErr: "omits the combined-use scope",
|
||||
},
|
||||
{
|
||||
name: "combined use prohibition preserves operation", content: "本品不得与阴离子成分同时使用。", from: "本品", to: "与阴离子成分同时使用", relation: "禁用于", evidence: "本品不得与阴离子成分同时使用",
|
||||
},
|
||||
{
|
||||
name: "combined use constraint preserves operation", content: "本品不可与其他产品一起使用。", from: "本品", to: "不可与其他产品合用", relation: "约束", evidence: "本品不可与其他产品一起使用",
|
||||
},
|
||||
{
|
||||
name: "substitution cannot become blanket drug prohibition", content: "本品不是药品 , 不得代替药品使用。", from: "本品", to: "药品", relation: "不适用于", evidence: "本品不是药品 , 不得代替药品使用",
|
||||
wantErr: "omits the substitution scope",
|
||||
},
|
||||
{
|
||||
name: "substitution prohibition preserves operation", content: "本品不是药品 , 不得代替药品使用。", from: "本品", to: "代替药品使用", relation: "不适用于", evidence: "本品不是药品 , 不得代替药品使用",
|
||||
},
|
||||
{
|
||||
name: "substitution constraint preserves operation", content: "本品不得替代药品。", from: "本品", to: "代替药品", relation: "约束", evidence: "本品不得替代药品",
|
||||
},
|
||||
{
|
||||
name: "constraint preserved", content: "本品只限于完整皮肤。", from: "本品", to: "完整皮肤", relation: "约束", evidence: "本品只限于完整皮肤",
|
||||
},
|
||||
{
|
||||
name: "avoidance is not conditional permission", content: "用于阴道时 , 注 0 避开女性崆期 、 产期 。", from: "本品", to: "产期", relation: "条件适用于", evidence: "用于阴道时 , 注 0 避开女性崆期 、 产期",
|
||||
wantErr: "conflicts with a source restriction",
|
||||
},
|
||||
{
|
||||
name: "do not use is not caution", content: "注意三期勿用。", from: "本品", to: "三期", relation: "慎用于", evidence: "注意三期勿用",
|
||||
wantErr: "weakens a source prohibition",
|
||||
},
|
||||
{
|
||||
name: "stop use cannot become caution", content: "使用过程中 , 如有不适 , 请立即停止使用 。", from: "本品", to: "如有不适", relation: "慎用于", evidence: "使用过程中 , 如有不适 , 请立即停止使用",
|
||||
wantErr: "weakens a source prohibition",
|
||||
},
|
||||
{
|
||||
name: "truncated quote cannot hide stop use", content: "使用过程中 , 如有不适 , 请立即停止使用 。", from: "本品", to: "不适", relation: "慎用于", evidence: "如有不适",
|
||||
wantErr: "weakens a source prohibition",
|
||||
},
|
||||
{
|
||||
name: "stop use constraint preserved", content: "使用过程中 , 如有不适 , 请立即停止使用 。", from: "本品", to: "不适时立即停止使用", relation: "约束", evidence: "使用过程中 , 如有不适 , 请立即停止使用",
|
||||
},
|
||||
{
|
||||
name: "suppression is not support of first microbe", content: "本品对白色念珠菌 , 金黄色葡萄球菌 , 大肠杆菌有抑制作用。", from: "本品", to: "白色念珠菌", relation: "支持", evidence: "本品对白色念珠菌 , 金黄色葡萄球菌 , 大肠杆菌有抑制作用",
|
||||
wantErr: "reverses source suppression",
|
||||
},
|
||||
{
|
||||
name: "suppression is not support of last microbe", content: "本品对白色念珠菌 , 金黄色葡萄球菌 , 大肠杆菌有抑制作用。", from: "本品", to: "大肠杆菌", relation: "支持", evidence: "本品对白色念珠菌 , 金黄色葡萄球菌 , 大肠杆菌有抑制作用",
|
||||
wantErr: "reverses source suppression",
|
||||
},
|
||||
{
|
||||
name: "literal suppression relation preserved", content: "本品对白色念珠菌有抑制作用。", from: "本品", to: "白色念珠菌", relation: "抑制", evidence: "本品对白色念珠菌有抑制作用",
|
||||
},
|
||||
{
|
||||
name: "suppression benefit is not reversed", content: "本品支持阴道抑菌。", from: "本品", to: "阴道抑菌", relation: "支持", evidence: "本品支持阴道抑菌",
|
||||
},
|
||||
{
|
||||
name: "benefit is not non applicability", content: "精华凝露激发内在欲望,告别冷淡。", from: "精华凝露", to: "冷淡", relation: "不适用于", evidence: "精华凝露激发内在欲望,告别冷淡",
|
||||
wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "benefit is not denied support", content: "精华凝露激发内在欲望,告别冷淡。", from: "精华凝露", to: "冷淡", relation: "不支持", evidence: "精华凝露激发内在欲望,告别冷淡",
|
||||
wantErr: "negative use label without a source restriction",
|
||||
},
|
||||
{
|
||||
name: "explicit denial preserved", content: "本品不具有治疗 、 护理 、 保健作用。", from: "本品", to: "治疗、护理、保健作用", relation: "不支持", evidence: "本品不具有治疗 、 护理 、 保健作用",
|
||||
},
|
||||
{
|
||||
name: "body site cannot be removed from prohibition", content: "哺乳期请勿在乳头使用。", from: "本品", to: "哺乳期", relation: "禁用于", evidence: "哺乳期请勿在乳头使用",
|
||||
wantErr: "omits the body-site scope",
|
||||
},
|
||||
{
|
||||
name: "scoped site prohibition preserved", content: "哺乳期请勿在乳头使用。", from: "本品", to: "哺乳期乳头使用", relation: "禁用于", evidence: "哺乳期请勿在乳头使用",
|
||||
},
|
||||
{
|
||||
name: "unrelated site prohibition not borrowed", content: "注意三期勿用 , 哺乳期请勿在乳头使用。", from: "本品", to: "三期", relation: "禁用于", evidence: "注意三期勿用 , 哺乳期请勿在乳头使用",
|
||||
},
|
||||
{
|
||||
name: "use scope cannot be removed from prohibition", content: "用于阴道时 , 注 0 避开女性崆期 、 产期。", from: "本品", to: "产期", relation: "禁用于", evidence: "用于阴道时 , 注 0 避开女性崆期 、 产期",
|
||||
wantErr: "omits the use scope",
|
||||
},
|
||||
{
|
||||
name: "scoped use prohibition preserved", content: "用于阴道时 , 注 0 避开女性崆期 、 产期。", from: "本品", to: "产期阴道使用", relation: "禁用于", evidence: "用于阴道时 , 注 0 避开女性崆期 、 产期",
|
||||
},
|
||||
{
|
||||
name: "invalid self relation is not silently discarded", content: "本品适用于成人", from: "本品", to: "本品", relation: "适用于", evidence: "本品适用于成人",
|
||||
wantErr: "is invalid",
|
||||
},
|
||||
{
|
||||
name: "quantity endpoint must match evidence", content: "紧紧丹富含3350ppm多肽。", from: "紧紧丹", to: "6650ppm多肽", relation: "包含", evidence: "富含3350ppm多肽",
|
||||
wantErr: "endpoint quantity absent",
|
||||
},
|
||||
{
|
||||
name: "quantity cannot come from another product", content: "紧紧丹富含3350ppm多肽。另一产品含6650ppm多肽。", from: "紧紧丹", to: "6650ppm多肽", relation: "包含", evidence: "富含3350ppm多肽",
|
||||
wantErr: "endpoint quantity absent",
|
||||
},
|
||||
{
|
||||
name: "quantity cannot match numeric substring", content: "紧紧丹富含3350ppm多肽。", from: "紧紧丹", to: "350ppm多肽", relation: "包含", evidence: "富含3350ppm多肽",
|
||||
wantErr: "endpoint quantity absent",
|
||||
},
|
||||
{
|
||||
name: "exact quantity preserved", content: "紧紧丹富含3350ppm多肽。", from: "紧紧丹", to: "3350ppm多肽", relation: "包含", evidence: "富含3350ppm多肽",
|
||||
},
|
||||
{
|
||||
name: "OCR decimal typography preserves amount", content: "本品含乳酸 2 . 0 % 。", from: "本品", to: "乳酸2.0%", relation: "包含", evidence: "本品含乳酸 2 . 0 %",
|
||||
},
|
||||
{
|
||||
name: "antibacterial activity is not suppressed", content: "私蜜叫溶菌酶 《 丰动抑菌连蛋白丰动修复", from: "私蜜叫溶菌酶", to: "抑菌", relation: "抑制", evidence: "私蜜叫溶菌酶 《 丰动抑菌连蛋白丰动修复",
|
||||
wantErr: "no explicit suppression predicate",
|
||||
},
|
||||
{
|
||||
name: "suppression predicate must be explicit", content: "本品对白色念珠菌有效。", from: "本品", to: "白色念珠菌", relation: "抑制", evidence: "本品对白色念珠菌有效",
|
||||
wantErr: "no explicit suppression predicate",
|
||||
},
|
||||
{
|
||||
name: "explicit suppression of activity is preserved", content: "此成分抑制抑菌反应。", from: "此成分", to: "抑菌反应", relation: "抑制", evidence: "此成分抑制抑菌反应",
|
||||
},
|
||||
{
|
||||
name: "antimicrobial predicate preserved", content: "本品对金黄色葡萄球菌有抗菌作用。", from: "本品", to: "金黄色葡萄球菌", relation: "抑制", evidence: "本品对金黄色葡萄球菌有抗菌作用",
|
||||
},
|
||||
{
|
||||
name: "storage is not conditional suitability", content: "〔 贮存条件 〗 请将本品置于阴凉干燥处存放。", from: "本品", to: "阴凉干燥处存放", relation: "条件适用于", evidence: "贮存条件 〗 请将本品置于阴凉干燥处存放",
|
||||
wantErr: "misclassifies a storage constraint",
|
||||
},
|
||||
{
|
||||
name: "storage condition retained", content: "〔 贮存条件 〗 请将本品置于阴凉干燥处存放。", from: "本品", to: "阴凉干燥处存放", relation: "约束", evidence: "贮存条件 〗 请将本品置于阴凉干燥处存放",
|
||||
},
|
||||
{
|
||||
name: "use cannot be inferred from benefit", content: "精华凝露提高女性阴蒂、阴道、G点部位的敏感度。", from: "精华凝露", to: "女性阴蒂、阴道、G点部位", relation: "适用于", evidence: "提高女性阴蒂、阴道、G点部位的敏感度",
|
||||
wantErr: "without an explicit source use claim",
|
||||
},
|
||||
{
|
||||
name: "use cannot be inferred from OCR fragment", content: "本品荫堆崆丰部。", from: "本品", to: "阴道", relation: "条件适用于", evidence: "本品荫堆崆丰部",
|
||||
wantErr: "without an explicit source use claim",
|
||||
},
|
||||
{
|
||||
name: "preceding fragment cannot belong to later product", content: "请将本品置于阴凉干燥处存放。\n\n〔 产品名称 〗 白番茄胶原蛋白肽饮品。", from: "白番茄胶原蛋白肽饮品", to: "阴凉干燥处存放", relation: "约束", evidence: "请将本品置于阴凉干燥处存放",
|
||||
wantErr: "earlier unnamed product fragment",
|
||||
},
|
||||
{
|
||||
name: "unresolved preceding product preserved", content: "请将本品置于阴凉干燥处存放。\n\n〔 产品名称 〗 白番茄胶原蛋白肽饮品。", from: "本品", to: "阴凉干燥处存放", relation: "约束", evidence: "请将本品置于阴凉干燥处存放",
|
||||
},
|
||||
{
|
||||
name: "same warning in named product also supported", content: "如有不适请停止使用。\n\n〔 产品名称 〗 柔肤霜。如有不适请停止使用。", from: "柔肤霜", to: "不适时停止使用", relation: "约束", evidence: "如有不适请停止使用",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
encoded, err := json.Marshal([]map[string]string{{
|
||||
"entity1": tc.from, "entity2": tc.to, "relation": tc.relation, "evidence": tc.evidence,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: string(encoded), FinishReason: "stop"}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), tc.content)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) || graph != nil {
|
||||
t.Fatalf("graph = %+v, error = %v; want nil graph and %q", graph, err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || graph == nil || len(graph.Relation) != 1 {
|
||||
t.Fatalf("graph = %+v, error = %v; want supported relation", graph, err)
|
||||
}
|
||||
if got := graph.Relation[0]; got.Evidence != tc.evidence || got.Type != tc.relation {
|
||||
t.Fatalf("relation was rewritten: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphExtractorContractDoesNotChangeQueryExtraction(t *testing.T) {
|
||||
template := &types.PromptTemplateStructured{
|
||||
Description: "Extract graph",
|
||||
Examples: []types.GraphData{{Text: "old positive-only example", Node: []*types.GraphNode{{Name: "example entity"}}}},
|
||||
}
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{
|
||||
Content: `[{"entity1":"Alice","entity2":"Bob","relation":"knows"}]`, FinishReason: "stop",
|
||||
}}
|
||||
queryExtractor := NewExtractor(model, template)
|
||||
graph, err := queryExtractor.Extract(context.Background(), "Alice knows Bob")
|
||||
if err != nil || len(graph.Relation) != 1 {
|
||||
t.Fatalf("query extraction = %+v, %v", graph, err)
|
||||
}
|
||||
if strings.Contains(model.messages[0].Content, "Source evidence for persisted graph relations") {
|
||||
t.Fatal("query extraction received graph-only instructions")
|
||||
}
|
||||
graphExtractor := NewGraphExtractor(model, template)
|
||||
if _, err := graphExtractor.Extract(context.Background(), "Alice knows Bob"); err == nil {
|
||||
t.Fatal("persisted graph accepted a relation without evidence")
|
||||
}
|
||||
if len(template.Examples) != 1 || len(graphExtractor.template.Examples) != 0 {
|
||||
t.Fatal("graph extraction mutated shared examples or kept ungrounded examples")
|
||||
}
|
||||
if !strings.Contains(model.messages[0].Content, "Source evidence for persisted graph relations") {
|
||||
t.Fatal("graph extraction is missing its evidence instructions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphExtractorRejectsPartialAndMalformedResults(t *testing.T) {
|
||||
for _, response := range []string{
|
||||
`[{"entity":"Alice"},{"unexpected":"ignored relationship"}]`,
|
||||
`[{"entity":42}]`,
|
||||
`[{"entity":"Alice","entity1":"Alice","entity2":"Bob","relation":"knows"}]`,
|
||||
`[{"entity":"Alice","entity2":"Bob","relation":"knows","evidence":"Alice knows Bob"}]`,
|
||||
`[{"entity1":"Alice","entity2":"Bob","evidence":"Alice knows Bob"}]`,
|
||||
`[{"entity1":"Alice","entity2":"Bob","relation":42,"evidence":"Alice knows Bob"}]`,
|
||||
`[{"entity1":"Alice","entity2":"Bob","relation":"knows","evidence":42}]`,
|
||||
`[{"entity":"Alice"},{"entity1":"Alice","entity2":"Bob","relation":"knows","evidence":"Alice knows Bob"},{"entity1":"Bob","entity2":"Charlie","relation":"knows","evidence":"invented"}]`,
|
||||
} {
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{Content: response, FinishReason: "stop"}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), "Alice knows Bob")
|
||||
if err == nil || graph != nil {
|
||||
t.Fatalf("accepted partial or malformed result: %+v, %v", graph, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphExtractorRejectsUnknownLabelsWithoutDroppingRelations(t *testing.T) {
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{
|
||||
Content: `[{"entity1":"Alice","entity2":"Bob","relation":"invented","evidence":"Alice knows Bob"}]`, FinishReason: "stop",
|
||||
}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{
|
||||
Description: "Use these types: %s", Tags: []string{"knows"},
|
||||
})
|
||||
graph, err := extractor.Extract(context.Background(), "Alice knows Bob")
|
||||
if graph != nil || err == nil || !strings.Contains(err.Error(), "outside the configured relation types") {
|
||||
t.Fatalf("graph = %+v, err = %v", graph, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphExtractorCorrectsValidationOnce(t *testing.T) {
|
||||
const source = "〔 注意事项 〗 不得用于治疗疾病 。"
|
||||
const repaired = `[{"entity1":"本品","entity2":"治疗疾病","relation":"不适用于","evidence":"不得用于治疗疾病"}]`
|
||||
for _, invalid := range []string{
|
||||
`[{"entity":"本品","entity2":"治疗疾病","relation":"不适用于","evidence":"不得用于治疗疾病"}]`,
|
||||
`[{"entity1":"本品","entity2":"治疗疾病","relation":"不适用于","evidence":"〖 注意事项 〗 不得用于治疗疾病 。"}]`,
|
||||
`[{"entity1":"本品","entity2":"治疗疾病","relation":"适用于","evidence":"用于治疗疾病"}]`,
|
||||
} {
|
||||
model := &extractionResponseChat{responses: []*types.ChatResponse{
|
||||
{Content: invalid, FinishReason: "stop"}, {Content: repaired, FinishReason: "stop"},
|
||||
}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if err != nil || graph == nil || len(graph.Relation) != 1 || model.calls != 2 {
|
||||
t.Fatalf("correction = %+v, %v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
if graph.Relation[0].Type != "不适用于" || graph.Relation[0].Evidence != "不得用于治疗疾病" {
|
||||
t.Fatalf("corrected relation changed: %+v", graph.Relation[0])
|
||||
}
|
||||
if len(model.messages) != 4 || model.messages[2].Content != invalid ||
|
||||
!strings.Contains(model.messages[3].Content, "failed validation") {
|
||||
t.Fatal("correction did not include rejected output and validation feedback")
|
||||
}
|
||||
}
|
||||
model := &extractionResponseChat{response: &types.ChatResponse{
|
||||
Content: `[{"entity1":"本品","entity2":"治疗疾病","relation":"适用于","evidence":"用于治疗疾病"}]`, FinishReason: "stop",
|
||||
}}
|
||||
extractor := NewGraphExtractor(model, &types.PromptTemplateStructured{Description: "Extract graph"})
|
||||
graph, err := extractor.Extract(context.Background(), source)
|
||||
if graph != nil || err == nil || model.calls != 2 {
|
||||
t.Fatalf("invalid correction was accepted or retried indefinitely: %+v, %v, calls=%d", graph, err, model.calls)
|
||||
}
|
||||
model.responses = []*types.ChatResponse{model.response, {Content: "[]", FinishReason: "stop"}}
|
||||
model.calls = 0
|
||||
graph, err = extractor.Extract(context.Background(), source)
|
||||
if graph != nil || err == nil || !strings.Contains(err.Error(), "no extracted content") {
|
||||
t.Fatalf("empty correction hid a validation failure: %+v, %v", graph, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormater_ParseGraph_FenceVariants exercises the JSON parsing path used
|
||||
// by the graph extraction pipeline against the LLM response shapes that
|
||||
// caused issue #1113. Each case feeds a raw LLM response string to
|
||||
// Formater.ParseGraph and asserts the resulting graph data, or that the
|
||||
// error path is preserved for genuinely invalid input.
|
||||
func TestFormater_ParseGraph_FenceVariants(t *testing.T) {
|
||||
const validJSON = `[
|
||||
{"entity": "Alice", "entity_attributes": ["person"]},
|
||||
{"entity": "Bob", "entity_attributes": ["person"]},
|
||||
{"entity1": "Alice", "entity2": "Bob", "relation": "knows"}
|
||||
]`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantNodes int
|
||||
wantRels int
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "wrapped in ```json fence",
|
||||
input: "```json\n" + validJSON + "\n```",
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
name: "wrapped in plain ``` fence (no language tag)",
|
||||
input: "```\n" + validJSON + "\n```",
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
name: "no fences at all (raw JSON)",
|
||||
input: validJSON,
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
name: "leading prose then ```json fence",
|
||||
input: "Here is the extracted graph:\n\n```json\n" + validJSON + "\n```",
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
name: "trailing prose after closing fence",
|
||||
input: "```json\n" + validJSON + "\n```\n\nHope this helps!",
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
name: "extra surrounding whitespace and newlines",
|
||||
input: "\n\n ```json\n\n" + validJSON + "\n\n``` \n",
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
// Issue #1113 Pattern 3: LLM hit max_tokens, no closing fence.
|
||||
// The response is structurally a JSON array we can still parse.
|
||||
name: "truncated response, opening ```json fence with no closer",
|
||||
input: "```json\n" + validJSON,
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
// Issue #1113 Pattern 1: bare backticks/markdown around JSON
|
||||
// without a well-formed fence pair.
|
||||
name: "stray backticks around JSON",
|
||||
input: "`" + validJSON + "`",
|
||||
wantNodes: 2,
|
||||
wantRels: 1,
|
||||
},
|
||||
{
|
||||
name: "JSON object embedded in prose (single dict)",
|
||||
input: "Result: {\"entity\": \"Alice\", \"entity_attributes\": [\"person\"]} -- end.",
|
||||
wantNodes: 1,
|
||||
wantRels: 0,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
errContains: "empty",
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
input: " \n\t ",
|
||||
wantErr: true,
|
||||
errContains: "empty",
|
||||
},
|
||||
{
|
||||
name: "fenced but body is invalid JSON",
|
||||
input: "```json\nnot json at all\n```",
|
||||
wantErr: true,
|
||||
errContains: "parse",
|
||||
},
|
||||
{
|
||||
name: "no recoverable JSON, only prose",
|
||||
input: "Sorry, I cannot extract a graph from this text.",
|
||||
wantErr: true,
|
||||
errContains: "parse",
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := NewFormater()
|
||||
graph, err := f.ParseGraph(ctx, tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil (graph=%+v)", graph)
|
||||
}
|
||||
if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) {
|
||||
t.Fatalf("error %q does not contain %q", err.Error(), tc.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if graph == nil {
|
||||
t.Fatalf("expected non-nil graph")
|
||||
}
|
||||
if got := len(graph.Node); got != tc.wantNodes {
|
||||
t.Errorf("nodes: got %d, want %d (graph=%+v)", got, tc.wantNodes, graph)
|
||||
}
|
||||
if got := len(graph.Relation); got != tc.wantRels {
|
||||
t.Errorf("relations: got %d, want %d (graph=%+v)", got, tc.wantRels, graph)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractJSONLike covers the JSON-substring extraction helper in
|
||||
// isolation. The helper is used by the fallback path in extractContent when
|
||||
// fences are missing or malformed.
|
||||
func TestExtractJSONLike(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"plain object", `{"a": 1}`, `{"a": 1}`},
|
||||
{"plain array", `[1, 2, 3]`, `[1, 2, 3]`},
|
||||
{"object in prose", `noise {"a": 1} tail`, `{"a": 1}`},
|
||||
{"array preferred when first", `tail [1] {"a":1}`, `[1]`},
|
||||
{"object preferred when first", `tail {"a":1} [1]`, `{"a":1}`},
|
||||
{"nested braces", `{"a": {"b": [1,2]}}`, `{"a": {"b": [1,2]}}`},
|
||||
{"brace inside string literal", `{"a": "}{not real}"}`, `{"a": "}{not real}"}`},
|
||||
{"escaped quote inside string", `{"a": "he said \"hi\""}`, `{"a": "he said \"hi\""}`},
|
||||
{"unbalanced object returns empty", `{"a": 1`, ""},
|
||||
{"no json", `just words`, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := extractJSONLike(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("extractJSONLike(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripFencesAndExtract focuses on the fence-recovery helper used as a
|
||||
// last resort when the main fence regex fails. Behavior must remain
|
||||
// conservative: return an empty string when nothing plausible can be
|
||||
// recovered, so the caller can fall through to existing behavior.
|
||||
func TestStripFencesAndExtract(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
format FormatType
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "open fence with json tag and no close",
|
||||
in: "```json\n{\"a\":1}",
|
||||
format: FormatTypeJSON,
|
||||
want: `{"a":1}`,
|
||||
},
|
||||
{
|
||||
name: "open fence with no tag and no close",
|
||||
in: "```\n[1,2]",
|
||||
format: FormatTypeJSON,
|
||||
want: `[1,2]`,
|
||||
},
|
||||
{
|
||||
name: "well-formed fence still recovers body",
|
||||
in: "```json\n{\"a\":1}\n```",
|
||||
format: FormatTypeJSON,
|
||||
want: `{"a":1}`,
|
||||
},
|
||||
{
|
||||
name: "no fence but embedded json object",
|
||||
in: "Sure! {\"a\":1} done.",
|
||||
format: FormatTypeJSON,
|
||||
want: `{"a":1}`,
|
||||
},
|
||||
{
|
||||
name: "no fence and no json",
|
||||
in: "just prose",
|
||||
format: FormatTypeJSON,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
in: "",
|
||||
format: FormatTypeJSON,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := stripFencesAndExtract(tc.in, tc.format)
|
||||
if got != tc.want {
|
||||
t.Errorf("stripFencesAndExtract(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsLikelyLanguageTag guards the heuristic used to drop language-tag
|
||||
// lines after an opening fence in the recovery path.
|
||||
func TestIsLikelyLanguageTag(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"json", true},
|
||||
{"yaml", true},
|
||||
{"yml", true},
|
||||
{"go", true},
|
||||
{"c++", true},
|
||||
{"objective-c", true},
|
||||
{"", false},
|
||||
{"this is not a tag", false},
|
||||
{`{"a":1}`, false},
|
||||
{strings.Repeat("a", 17), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := isLikelyLanguageTag(tc.in); got != tc.want {
|
||||
t.Errorf("isLikelyLanguageTag(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// PluginFilterTopK is a plugin that filters search results to keep only the top K items
|
||||
type PluginFilterTopK struct{}
|
||||
|
||||
// NewPluginFilterTopK creates a new instance of PluginFilterTopK and registers it with the event manager
|
||||
func NewPluginFilterTopK(eventManager *EventManager) *PluginFilterTopK {
|
||||
res := &PluginFilterTopK{}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types that this plugin responds to
|
||||
func (p *PluginFilterTopK) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.FILTER_TOP_K}
|
||||
}
|
||||
|
||||
// OnEvent handles the FILTER_TOP_K event by filtering results to keep only the top K items
|
||||
// It can filter MergeResult, RerankResult, or SearchResult depending on which is available
|
||||
func (p *PluginFilterTopK) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
return next()
|
||||
}
|
||||
pipelineInfo(ctx, "FilterTopK", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"top_k": chatManage.RerankTopK,
|
||||
"merge_cnt": len(chatManage.MergeResult),
|
||||
"rerank_cnt": len(chatManage.RerankResult),
|
||||
"search_cnt": len(chatManage.SearchResult),
|
||||
})
|
||||
|
||||
filterTopK := func(searchResult []*types.SearchResult, topK int) []*types.SearchResult {
|
||||
sortSearchResultsDeterministically(searchResult)
|
||||
if topK > 0 && len(searchResult) > topK {
|
||||
pipelineInfo(ctx, "FilterTopK", "filter", map[string]interface{}{
|
||||
"before": len(searchResult),
|
||||
"after": topK,
|
||||
})
|
||||
searchResult = searchResult[:topK]
|
||||
}
|
||||
return searchResult
|
||||
}
|
||||
|
||||
if len(chatManage.MergeResult) > 0 {
|
||||
chatManage.MergeResult = filterTopK(chatManage.MergeResult, chatManage.RerankTopK)
|
||||
} else if len(chatManage.RerankResult) > 0 {
|
||||
chatManage.RerankResult = filterTopK(chatManage.RerankResult, chatManage.RerankTopK)
|
||||
} else if len(chatManage.SearchResult) > 0 {
|
||||
chatManage.SearchResult = filterTopK(chatManage.SearchResult, chatManage.RerankTopK)
|
||||
} else {
|
||||
pipelineWarn(ctx, "FilterTopK", "skip", map[string]interface{}{
|
||||
"reason": "no_results",
|
||||
})
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "FilterTopK", "output", map[string]interface{}{
|
||||
"merge_cnt": len(chatManage.MergeResult),
|
||||
"rerank_cnt": len(chatManage.RerankResult),
|
||||
"search_cnt": len(chatManage.SearchResult),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
// sortSearchResultsDeterministically restores the global relevance order after
|
||||
// merge stages group results through maps. Stable tie-breakers keep identical
|
||||
// requests reproducible before TopK truncation.
|
||||
func sortSearchResultsDeterministically(results []*types.SearchResult) {
|
||||
sort.SliceStable(results, func(i, j int) bool {
|
||||
left, right := results[i], results[j]
|
||||
if left == nil || right == nil {
|
||||
return left != nil
|
||||
}
|
||||
if left.Score != right.Score {
|
||||
return left.Score > right.Score
|
||||
}
|
||||
if left.KnowledgeID != right.KnowledgeID {
|
||||
return left.KnowledgeID < right.KnowledgeID
|
||||
}
|
||||
if left.ChunkType != right.ChunkType {
|
||||
return left.ChunkType < right.ChunkType
|
||||
}
|
||||
if left.ChunkIndex != right.ChunkIndex {
|
||||
return left.ChunkIndex < right.ChunkIndex
|
||||
}
|
||||
return left.ID < right.ID
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPluginFilterTopKSortsMergeResultsBeforeTruncation(t *testing.T) {
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{RerankTopK: 3},
|
||||
PipelineState: types.PipelineState{
|
||||
MergeResult: []*types.SearchResult{
|
||||
{ID: "low", KnowledgeID: "doc-c", Score: 0.2},
|
||||
{ID: "high", KnowledgeID: "doc-a", Score: 0.9},
|
||||
{ID: "medium", KnowledgeID: "doc-b", Score: 0.5},
|
||||
{ID: "second", KnowledgeID: "doc-d", Score: 0.8},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
plugin := &PluginFilterTopK{}
|
||||
err := plugin.OnEvent(
|
||||
context.Background(),
|
||||
types.FILTER_TOP_K,
|
||||
chatManage,
|
||||
func() *PluginError { return nil },
|
||||
)
|
||||
|
||||
require.Nil(t, err)
|
||||
require.Len(t, chatManage.MergeResult, 3)
|
||||
assert.Equal(t, []string{"high", "second", "medium"}, searchResultIDs(chatManage.MergeResult))
|
||||
}
|
||||
|
||||
func TestPluginFilterTopKUsesDeterministicTieBreakers(t *testing.T) {
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{RerankTopK: 10},
|
||||
PipelineState: types.PipelineState{
|
||||
MergeResult: []*types.SearchResult{
|
||||
{ID: "chunk-b", KnowledgeID: "doc-b", ChunkType: "text", StartAt: 10, EndAt: 20, Score: 0.8},
|
||||
{ID: "chunk-c", KnowledgeID: "doc-a", ChunkType: "summary", StartAt: 0, EndAt: 10, Score: 0.8},
|
||||
{ID: "chunk-a", KnowledgeID: "doc-a", ChunkType: "text", StartAt: 0, EndAt: 10, Score: 0.8},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
plugin := &PluginFilterTopK{}
|
||||
err := plugin.OnEvent(
|
||||
context.Background(),
|
||||
types.FILTER_TOP_K,
|
||||
chatManage,
|
||||
func() *PluginError { return nil },
|
||||
)
|
||||
|
||||
require.Nil(t, err)
|
||||
assert.Equal(t, []string{"chunk-c", "chunk-a", "chunk-b"}, searchResultIDs(chatManage.MergeResult))
|
||||
}
|
||||
|
||||
func searchResultIDs(results []*types.SearchResult) []string {
|
||||
ids := make([]string, 0, len(results))
|
||||
for _, result := range results {
|
||||
ids = append(ids, result.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// PluginIntoChatMessage handles the transformation of search results into chat messages
|
||||
type PluginIntoChatMessage struct {
|
||||
messageService interfaces.MessageService
|
||||
}
|
||||
|
||||
// NewPluginIntoChatMessage creates and registers a new PluginIntoChatMessage instance
|
||||
func NewPluginIntoChatMessage(eventManager *EventManager, messageService interfaces.MessageService) *PluginIntoChatMessage {
|
||||
res := &PluginIntoChatMessage{messageService: messageService}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginIntoChatMessage) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.INTO_CHAT_MESSAGE}
|
||||
}
|
||||
|
||||
// OnEvent processes the INTO_CHAT_MESSAGE event to format chat message content
|
||||
func (p *PluginIntoChatMessage) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
pipelineInfo(ctx, "IntoChatMessage", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"merge_result_cnt": len(chatManage.MergeResult),
|
||||
"template_len": len(chatManage.SummaryConfig.ContextTemplate),
|
||||
})
|
||||
|
||||
// Separate FAQ and document results when FAQ priority is enabled
|
||||
var faqResults, docResults []*types.SearchResult
|
||||
var hasHighConfidenceFAQ bool
|
||||
|
||||
if chatManage.FAQPriorityEnabled {
|
||||
for _, result := range chatManage.MergeResult {
|
||||
if result.ChunkType == string(types.ChunkTypeFAQ) {
|
||||
faqResults = append(faqResults, result)
|
||||
// Check if this FAQ has high confidence (above direct answer threshold)
|
||||
if result.Score >= chatManage.FAQDirectAnswerThreshold && !hasHighConfidenceFAQ {
|
||||
hasHighConfidenceFAQ = true
|
||||
pipelineInfo(ctx, "IntoChatMessage", "high_confidence_faq", map[string]interface{}{
|
||||
"chunk_id": result.ID,
|
||||
"score": fmt.Sprintf("%.4f", result.Score),
|
||||
"threshold": chatManage.FAQDirectAnswerThreshold,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
docResults = append(docResults, result)
|
||||
}
|
||||
}
|
||||
pipelineInfo(ctx, "IntoChatMessage", "faq_separation", map[string]interface{}{
|
||||
"faq_count": len(faqResults),
|
||||
"doc_count": len(docResults),
|
||||
"has_high_confidence": hasHighConfidenceFAQ,
|
||||
})
|
||||
}
|
||||
|
||||
// 验证用户查询的安全性
|
||||
safeQuery, isValid := utils.ValidateInput(chatManage.Query)
|
||||
if !isValid {
|
||||
pipelineWarn(ctx, "IntoChatMessage", "invalid_query", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
return ErrTemplateExecute.WithError(fmt.Errorf("user query contains invalid content"))
|
||||
}
|
||||
|
||||
// Intent-based no-search path: no retrieval results, but still render
|
||||
// through the context template so runtime metadata (current_time, etc.) is injected.
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
userContent := safeQuery
|
||||
if rewrite := strings.TrimSpace(chatManage.RewriteQuery); rewrite != "" {
|
||||
if safeRewrite, ok := utils.ValidateInput(rewrite); ok {
|
||||
userContent = safeRewrite
|
||||
} else {
|
||||
pipelineWarn(ctx, "IntoChatMessage", "invalid_rewrite_query_fallback", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
if chatManage.ImageDescription != "" && !chatManage.ChatModelSupportsVision {
|
||||
userContent += "\n\n[用户上传图片内容]\n" + chatManage.ImageDescription
|
||||
}
|
||||
if chatManage.QuotedContext != "" {
|
||||
userContent += "\n\n" + chatManage.QuotedContext
|
||||
}
|
||||
// Inject attachment content (documents, audio transcripts, etc.)
|
||||
if len(chatManage.Attachments) > 0 {
|
||||
userContent += chatManage.Attachments.BuildPrompt()
|
||||
}
|
||||
|
||||
if tpl := chatManage.SummaryConfig.ContextTemplate; tpl != "" {
|
||||
chatManage.UserContent = types.RenderPromptPlaceholders(tpl, types.PlaceholderValues{
|
||||
"query": userContent,
|
||||
"contexts": "",
|
||||
"language": chatManage.Language,
|
||||
})
|
||||
} else {
|
||||
chatManage.UserContent = userContent
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "IntoChatMessage", "no_search_with_template", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_content_len": len(chatManage.UserContent),
|
||||
"has_template": chatManage.SummaryConfig.ContextTemplate != "",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
var contextsBuilder strings.Builder
|
||||
|
||||
// Collect unique document metadata (title + description), once per knowledge
|
||||
allResults := chatManage.MergeResult
|
||||
if chatManage.FAQPriorityEnabled && len(faqResults) > 0 {
|
||||
allResults = append(faqResults, docResults...)
|
||||
}
|
||||
docHeader := buildDocumentHeader(allResults)
|
||||
if docHeader != "" {
|
||||
contextsBuilder.WriteString(docHeader)
|
||||
contextsBuilder.WriteString("\n")
|
||||
}
|
||||
|
||||
// Build contexts string based on FAQ priority strategy
|
||||
if chatManage.FAQPriorityEnabled && len(faqResults) > 0 {
|
||||
contextsBuilder.WriteString("<source type=\"faq\" priority=\"high\">\n")
|
||||
for i, result := range faqResults {
|
||||
passage := getEnrichedPassageForChat(ctx, result)
|
||||
if hasHighConfidenceFAQ && i == 0 {
|
||||
contextsBuilder.WriteString(fmt.Sprintf("<context id=\"FAQ-%d\" match=\"exact\">%s</context>\n", i+1, passage))
|
||||
} else {
|
||||
contextsBuilder.WriteString(fmt.Sprintf("<context id=\"FAQ-%d\">%s</context>\n", i+1, passage))
|
||||
}
|
||||
}
|
||||
contextsBuilder.WriteString("</source>\n")
|
||||
|
||||
if len(docResults) > 0 {
|
||||
contextsBuilder.WriteString("<source type=\"document\" priority=\"supplementary\">\n")
|
||||
for i, result := range docResults {
|
||||
passage := getEnrichedPassageForChat(ctx, result)
|
||||
contextsBuilder.WriteString(fmt.Sprintf("<context id=\"DOC-%d\">%s</context>\n", i+1, passage))
|
||||
}
|
||||
contextsBuilder.WriteString("</source>")
|
||||
}
|
||||
} else {
|
||||
for i, result := range chatManage.MergeResult {
|
||||
passage := getEnrichedPassageForChat(ctx, result)
|
||||
if i > 0 {
|
||||
contextsBuilder.WriteString("\n")
|
||||
}
|
||||
contextsBuilder.WriteString(fmt.Sprintf("<context id=\"%d\">%s</context>", i+1, passage))
|
||||
}
|
||||
}
|
||||
|
||||
chatManage.RenderedContexts = contextsBuilder.String()
|
||||
|
||||
// Replace placeholders in context template
|
||||
userContent := types.RenderPromptPlaceholders(chatManage.SummaryConfig.ContextTemplate, types.PlaceholderValues{
|
||||
"query": safeQuery,
|
||||
"contexts": chatManage.RenderedContexts,
|
||||
"language": chatManage.Language,
|
||||
})
|
||||
|
||||
// Append image description as text fallback only when the chat model cannot
|
||||
// process images directly. Vision-capable models see images via MultiContent.
|
||||
if chatManage.ImageDescription != "" && !chatManage.ChatModelSupportsVision {
|
||||
userContent += "\n\n[用户上传图片内容]\n" + chatManage.ImageDescription
|
||||
}
|
||||
if chatManage.QuotedContext != "" {
|
||||
userContent += "\n\n" + chatManage.QuotedContext
|
||||
}
|
||||
// Inject attachment content (documents, audio transcripts, etc.)
|
||||
if len(chatManage.Attachments) > 0 {
|
||||
userContent += chatManage.Attachments.BuildPrompt()
|
||||
}
|
||||
|
||||
// Set formatted content back to chat management
|
||||
chatManage.UserContent = userContent
|
||||
pipelineInfo(ctx, "IntoChatMessage", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_content_len": len(chatManage.UserContent),
|
||||
"faq_priority": chatManage.FAQPriorityEnabled,
|
||||
"intent": chatManage.Intent,
|
||||
"image_description": chatManage.ImageDescription,
|
||||
"chat_model_supports_vision": chatManage.ChatModelSupportsVision,
|
||||
})
|
||||
|
||||
p.persistRenderedContent(ctx, chatManage)
|
||||
return next()
|
||||
}
|
||||
|
||||
// persistRenderedContent asynchronously writes the RAG-augmented UserContent back
|
||||
// to the user message so that subsequent conversation turns can see the full
|
||||
// retrieval context in history.
|
||||
func (p *PluginIntoChatMessage) persistRenderedContent(ctx context.Context, chatManage *types.ChatManage) {
|
||||
if chatManage.UserMessageID == "" || chatManage.UserContent == "" {
|
||||
pipelineInfo(ctx, "IntoChatMessage", "persist_rendered_content_skip", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_message_id": chatManage.UserMessageID,
|
||||
"has_user_content": chatManage.UserContent != "",
|
||||
"reason": "empty_id_or_content",
|
||||
})
|
||||
return
|
||||
}
|
||||
if chatManage.UserContent == chatManage.Query {
|
||||
return
|
||||
}
|
||||
pipelineInfo(ctx, "IntoChatMessage", "persist_rendered_content", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_message_id": chatManage.UserMessageID,
|
||||
"rendered_content_len": len(chatManage.UserContent),
|
||||
})
|
||||
bgCtx := context.WithoutCancel(ctx)
|
||||
go func() {
|
||||
if err := p.messageService.UpdateMessageRenderedContent(
|
||||
bgCtx, chatManage.SessionID, chatManage.UserMessageID, chatManage.UserContent,
|
||||
); err != nil {
|
||||
pipelineWarn(bgCtx, "IntoChatMessage", "persist_rendered_content_error", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_message_id": chatManage.UserMessageID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// buildDocumentHeader generates a document metadata section listing each unique
|
||||
// knowledge document (by KnowledgeID) with its title and description.
|
||||
// Returns an empty string when no meaningful metadata is available.
|
||||
func buildDocumentHeader(results []*types.SearchResult) string {
|
||||
type docMeta struct {
|
||||
title string
|
||||
description string
|
||||
metadata string
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var docs []docMeta
|
||||
|
||||
for _, r := range results {
|
||||
if r.KnowledgeID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[r.KnowledgeID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[r.KnowledgeID] = struct{}{}
|
||||
|
||||
title := r.KnowledgeTitle
|
||||
if title == "" {
|
||||
title = r.KnowledgeFilename
|
||||
}
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
docs = append(docs, docMeta{
|
||||
title: title,
|
||||
description: r.KnowledgeDescription,
|
||||
metadata: r.KnowledgeCustomMetadata,
|
||||
})
|
||||
}
|
||||
|
||||
if len(docs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("<documents>\n")
|
||||
for _, d := range docs {
|
||||
b.WriteString("<document>\n")
|
||||
b.WriteString(fmt.Sprintf("<title>%s</title>\n", html.EscapeString(d.title)))
|
||||
if d.description != "" {
|
||||
b.WriteString(fmt.Sprintf("<description>%s</description>\n", html.EscapeString(d.description)))
|
||||
}
|
||||
if d.metadata != "" {
|
||||
b.WriteString(fmt.Sprintf("<metadata>%s</metadata>\n", html.EscapeString(d.metadata)))
|
||||
}
|
||||
b.WriteString("</document>\n")
|
||||
}
|
||||
b.WriteString("</documents>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// getEnrichedPassageForChat 合并Content和ImageInfo的文本内容,为聊天消息准备
|
||||
func getEnrichedPassageForChat(ctx context.Context, result *types.SearchResult) string {
|
||||
// 如果没有图片信息,直接返回内容
|
||||
if result.Content == "" && result.ImageInfo == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 如果只有内容,没有图片信息
|
||||
if result.ImageInfo == "" {
|
||||
return result.Content
|
||||
}
|
||||
|
||||
// 处理图片信息并与内容合并
|
||||
return enrichContentWithImageInfo(ctx, result.Content, result.ImageInfo)
|
||||
}
|
||||
|
||||
// enrichContentWithImageInfo delegates to the shared searchutil implementation.
|
||||
func enrichContentWithImageInfo(_ context.Context, content string, imageInfoJSON string) string {
|
||||
return searchutil.EnrichContentWithImageInfoForChat(content, imageInfoJSON)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestBuildDocumentHeaderEscapesCustomMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
header := buildDocumentHeader([]*types.SearchResult{{
|
||||
KnowledgeID: "knowledge-1",
|
||||
KnowledgeTitle: "Doc <title>",
|
||||
KnowledgeDescription: "desc & more",
|
||||
KnowledgeCustomMetadata: "region: Shanghai\nnote: </metadata><chunk>",
|
||||
}})
|
||||
|
||||
if strings.Contains(header, "<title>Doc <title></title>") {
|
||||
t.Fatalf("title was not escaped: %q", header)
|
||||
}
|
||||
if !strings.Contains(header, "Doc <title>") {
|
||||
t.Fatalf("expected escaped title, got %q", header)
|
||||
}
|
||||
if !strings.Contains(header, "</metadata><chunk>") {
|
||||
t.Fatalf("expected escaped metadata payload, got %q", header)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
type PluginLoadHistory struct {
|
||||
messageService interfaces.MessageService
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewPluginLoadHistory(eventManager *EventManager,
|
||||
messageService interfaces.MessageService,
|
||||
config *config.Config,
|
||||
) *PluginLoadHistory {
|
||||
res := &PluginLoadHistory{
|
||||
messageService: messageService,
|
||||
config: config,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
func (p *PluginLoadHistory) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.LOAD_HISTORY}
|
||||
}
|
||||
|
||||
func (p *PluginLoadHistory) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
// chatManage.MaxRounds == 0 means multi-turn is explicitly disabled
|
||||
// (e.g. by a custom agent with MultiTurnEnabled=false). Skip loading so
|
||||
// history doesn't leak into the LLM context. We do NOT fall back to the
|
||||
// global Conversation.MaxRounds default here, otherwise the disable flag
|
||||
// would be silently overridden.
|
||||
if chatManage.MaxRounds <= 0 {
|
||||
pipelineInfo(ctx, "LoadHistory", "skipped", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"reason": "multi_turn_disabled",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
maxRounds := chatManage.MaxRounds
|
||||
|
||||
pipelineInfo(ctx, "LoadHistory", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"max_rounds": maxRounds,
|
||||
})
|
||||
|
||||
historyList, err := loadAndProcessHistory(ctx, p.messageService, chatManage.SessionID, maxRounds, maxRounds*2+10)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "LoadHistory", "history_fetch", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
chatManage.History = historyList
|
||||
|
||||
pipelineInfo(ctx, "LoadHistory", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"history_rounds": len(historyList),
|
||||
"max_rounds": maxRounds,
|
||||
})
|
||||
|
||||
return next()
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// Affinity boost bounds.
|
||||
//
|
||||
// The multiplier is capped well below the wiki boost because the signal is
|
||||
// weaker: a document appearing in past answers means the retriever kept picking
|
||||
// it, not that the user found it useful. The boost is meant to break ties
|
||||
// between comparable passages, never to drag an irrelevant document to the top
|
||||
// of a question it has nothing to do with.
|
||||
const (
|
||||
affinityMaxBoost = 1.15
|
||||
affinityFullHits = 8.0
|
||||
affinityMinHits = types.MemoryDocAffinityMinHits
|
||||
affinityMaxLookup = 200
|
||||
)
|
||||
|
||||
// PluginMemoryAffinity prefers documents this person's answers keep drawing on.
|
||||
//
|
||||
// This exists because personalising the answer prompt while retrieving exactly
|
||||
// the same passages for everyone is the shallow half of a memory feature. In a
|
||||
// knowledge-base product the durable per-person signal is which material they
|
||||
// actually work from, and the reranker is where it belongs.
|
||||
//
|
||||
// The table it reads is written by the same feature that reads it. The previous
|
||||
// attempt at this shipped an anchor table with no consumer; the rule since is
|
||||
// that a per-person retrieval signal ships with the code that uses it.
|
||||
type PluginMemoryAffinity struct {
|
||||
memoryService interfaces.MemoryService
|
||||
}
|
||||
|
||||
// NewPluginMemoryAffinity creates and registers the affinity rerank plugin.
|
||||
func NewPluginMemoryAffinity(
|
||||
eventManager *EventManager, memoryService interfaces.MemoryService,
|
||||
) *PluginMemoryAffinity {
|
||||
p := &PluginMemoryAffinity{memoryService: memoryService}
|
||||
eventManager.Register(p)
|
||||
return p
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles.
|
||||
func (p *PluginMemoryAffinity) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHUNK_RERANK}
|
||||
}
|
||||
|
||||
// OnEvent applies the per-person document boost after reranking.
|
||||
func (p *PluginMemoryAffinity) OnEvent(
|
||||
ctx context.Context,
|
||||
eventType types.EventType,
|
||||
chatManage *types.ChatManage,
|
||||
next func() *PluginError,
|
||||
) *PluginError {
|
||||
if err := next(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.memoryService == nil || len(chatManage.RerankResult) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(chatManage.RerankResult))
|
||||
seen := make(map[string]struct{}, len(chatManage.RerankResult))
|
||||
for i := range chatManage.RerankResult {
|
||||
id := chatManage.RerankResult[i].KnowledgeID
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
if len(ids) >= affinityMaxLookup {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
affinity := p.memoryService.DocumentAffinity(ctx, ids)
|
||||
if len(affinity) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
boosted := 0
|
||||
for i := range chatManage.RerankResult {
|
||||
hits := affinity[chatManage.RerankResult[i].KnowledgeID]
|
||||
if hits < affinityMinHits {
|
||||
continue
|
||||
}
|
||||
chatManage.RerankResult[i].Score *= affinityFactor(hits)
|
||||
boosted++
|
||||
}
|
||||
if boosted == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.SliceStable(chatManage.RerankResult, func(i, j int) bool {
|
||||
return chatManage.RerankResult[i].Score > chatManage.RerankResult[j].Score
|
||||
})
|
||||
logger.Infof(ctx, "MemoryAffinity: boosted %d chunks from familiar documents", boosted)
|
||||
return nil
|
||||
}
|
||||
|
||||
// affinityFactor grows with use and saturates.
|
||||
//
|
||||
// The curve is logarithmic so the tenth reuse of a document counts for far less
|
||||
// than the second: familiarity should be a nudge that compounds slowly, not a
|
||||
// feedback loop that locks a person into the first document they ever opened.
|
||||
func affinityFactor(hits int) float64 {
|
||||
if hits < affinityMinHits {
|
||||
return 1
|
||||
}
|
||||
ratio := math.Log1p(float64(hits)) / math.Log1p(affinityFullHits)
|
||||
if ratio > 1 {
|
||||
ratio = 1
|
||||
}
|
||||
return 1 + (affinityMaxBoost-1)*ratio
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A document appearing in past answers means the retriever kept picking it,
|
||||
// not that the user found it useful. The boost it earns has to stay small
|
||||
// enough to break ties between comparable passages and never large enough to
|
||||
// drag an unrelated document to the top.
|
||||
func TestAffinityBoostIsBoundedAndSaturates(t *testing.T) {
|
||||
require.Equal(t, 1.0, affinityFactor(1),
|
||||
"a single sighting is not evidence of anything")
|
||||
require.Greater(t, affinityFactor(2), 1.0)
|
||||
require.Greater(t, affinityFactor(8), affinityFactor(3))
|
||||
require.LessOrEqual(t, affinityFactor(1000), affinityMaxBoost,
|
||||
"familiarity must not become a feedback loop that locks someone in")
|
||||
|
||||
// The tenth reuse counts for far less than the second.
|
||||
require.Less(t, affinityFactor(10)-affinityFactor(8), affinityFactor(3)-affinityFactor(2))
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// emitMemoryRecalled tells the client which memories this turn saw. Best
|
||||
// effort: an event failure must not stop the answer.
|
||||
func emitMemoryRecalled(
|
||||
ctx context.Context, bus types.EventBusInterface, sessionID string, used types.UsedMemories,
|
||||
) {
|
||||
if bus == nil || len(used) == 0 {
|
||||
return
|
||||
}
|
||||
if err := bus.Emit(ctx, types.Event{
|
||||
Type: types.EventType(event.EventMemoryRecalled),
|
||||
SessionID: sessionID,
|
||||
Data: event.MemoryRecalledData{Memories: used},
|
||||
}); err != nil {
|
||||
pipelineWarn(ctx, "MemoryRecall", "emit_failed", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// PluginMemoryRecall injects the user's long-term memory into the turn.
|
||||
//
|
||||
// It runs after LOAD_HISTORY and before retrieval so the memory is available
|
||||
// to every downstream stage, and it performs no model call: recall is a
|
||||
// primary-key read for the resident block plus lexical matching over a few
|
||||
// hundred short rows. A turn's first token must not wait on memory.
|
||||
type PluginMemoryRecall struct {
|
||||
memoryService interfaces.MemoryService
|
||||
}
|
||||
|
||||
func NewPluginMemoryRecall(
|
||||
eventManager *EventManager,
|
||||
memoryService interfaces.MemoryService,
|
||||
) *PluginMemoryRecall {
|
||||
res := &PluginMemoryRecall{memoryService: memoryService}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
func (p *PluginMemoryRecall) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.MEMORY_RECALL}
|
||||
}
|
||||
|
||||
func (p *PluginMemoryRecall) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
if p.memoryService == nil {
|
||||
pipelineInfo(ctx, "MemoryRecall", "skip", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"reason": "no_service",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "MemoryRecall", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"query_len": len(chatManage.Query),
|
||||
"query_preview": langfuse.TruncateRunes(chatManage.Query, 200),
|
||||
})
|
||||
|
||||
recall := p.memoryService.Recall(ctx, chatManage.Query)
|
||||
if recall.Prompt == "" {
|
||||
pipelineInfo(ctx, "MemoryRecall", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"items": 0,
|
||||
"injected": false,
|
||||
"note": "interest memories apply in query_understand, not here",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
chatManage.MemoryPrompt = recall.Prompt
|
||||
chatManage.UsedMemories = types.UsedMemoriesFromItems(recall.Items)
|
||||
emitMemoryRecalled(ctx, chatManage.EventBus, chatManage.SessionID, chatManage.UsedMemories)
|
||||
|
||||
memoryIDs := make([]string, 0, len(recall.Items))
|
||||
for _, item := range recall.Items {
|
||||
if item != nil && item.ID != "" {
|
||||
memoryIDs = append(memoryIDs, item.ID)
|
||||
}
|
||||
}
|
||||
pipelineInfo(ctx, "MemoryRecall", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"items": len(chatManage.UsedMemories),
|
||||
"injected": true,
|
||||
"prompt_runes": len([]rune(recall.Prompt)),
|
||||
"memory_ids": memoryIDs,
|
||||
"query_preview": langfuse.TruncateRunes(chatManage.Query, 200),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubMemoryService returns a fixed recall so the test can assert what the
|
||||
// pipeline does with it, rather than re-testing the memory service.
|
||||
type stubMemoryService struct {
|
||||
interfaces.MemoryService
|
||||
|
||||
recall interfaces.MemoryRecall
|
||||
lastQuery string
|
||||
recallCall int
|
||||
}
|
||||
|
||||
func (s *stubMemoryService) Recall(_ context.Context, query string) interfaces.MemoryRecall {
|
||||
s.recallCall++
|
||||
s.lastQuery = query
|
||||
return s.recall
|
||||
}
|
||||
|
||||
func (s *stubMemoryService) ScheduleExtraction(context.Context, string, string, string) {}
|
||||
|
||||
func (s *stubMemoryService) Handle(context.Context, *asynq.Task) error { return nil }
|
||||
|
||||
func newMemoryRecallPlugin(memoryService interfaces.MemoryService) *PluginMemoryRecall {
|
||||
return NewPluginMemoryRecall(NewEventManager(), memoryService)
|
||||
}
|
||||
|
||||
// TestMemoryReachesTheMessagesSentToTheModel is the assertion that matters:
|
||||
// recalling memory is pointless if it never lands in the request. It walks the
|
||||
// recall stage and then the same message assembly the completion plugins use.
|
||||
func TestMemoryReachesTheMessagesSentToTheModel(t *testing.T) {
|
||||
memoryService := &stubMemoryService{
|
||||
recall: interfaces.MemoryRecall{
|
||||
Prompt: types.WrapMemoryForPrompt("Preferences:\n- 回答请直接给结论", ""),
|
||||
Items: []*types.MemoryItem{
|
||||
{ID: "m1", Kind: types.MemoryKindPreference, Content: "回答请直接给结论"},
|
||||
},
|
||||
},
|
||||
}
|
||||
plugin := newMemoryRecallPlugin(memoryService)
|
||||
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.Query = "帮我看看这个报错"
|
||||
chatManage.UserContent = "帮我看看这个报错"
|
||||
chatManage.SummaryConfig.Prompt = "你是一个助手。"
|
||||
|
||||
nextCalled := false
|
||||
err := plugin.OnEvent(t.Context(), types.MEMORY_RECALL, chatManage, func() *PluginError {
|
||||
nextCalled = true
|
||||
return nil
|
||||
})
|
||||
require.Nil(t, err)
|
||||
require.True(t, nextCalled, "the recall stage must never stop the pipeline")
|
||||
require.Equal(t, "帮我看看这个报错", memoryService.lastQuery)
|
||||
|
||||
messages := prepareMessagesWithHistory(chatManage)
|
||||
require.NotEmpty(t, messages)
|
||||
require.Equal(t, "system", messages[0].Role)
|
||||
require.Contains(t, messages[0].Content, "回答请直接给结论",
|
||||
"the recalled memory must be present in the system message")
|
||||
require.Contains(t, messages[0].Content, "<user_memory>")
|
||||
require.True(t, strings.HasPrefix(messages[0].Content, "你是一个助手。"),
|
||||
"memory must be appended after the configured prompt, not replace it")
|
||||
}
|
||||
|
||||
func TestMemoryIsAbsentWhenNothingRecalled(t *testing.T) {
|
||||
plugin := newMemoryRecallPlugin(&stubMemoryService{})
|
||||
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.Query = "随便问点什么"
|
||||
chatManage.SummaryConfig.Prompt = "你是一个助手。"
|
||||
|
||||
require.Nil(t, plugin.OnEvent(t.Context(), types.MEMORY_RECALL, chatManage, func() *PluginError { return nil }))
|
||||
require.Empty(t, chatManage.MemoryPrompt)
|
||||
require.Empty(t, chatManage.UsedMemories)
|
||||
|
||||
messages := prepareMessagesWithHistory(chatManage)
|
||||
require.NotContains(t, messages[0].Content, "<user_memory>")
|
||||
}
|
||||
|
||||
func TestMemoryRecallEmitsWhatTheAnswerSaw(t *testing.T) {
|
||||
memoryService := &stubMemoryService{
|
||||
recall: interfaces.MemoryRecall{
|
||||
Prompt: types.WrapMemoryForPrompt("About the user:\n- 在做医疗影像", ""),
|
||||
Items: []*types.MemoryItem{
|
||||
{ID: "m1", Kind: types.MemoryKindProfile, Content: "在做医疗影像"},
|
||||
},
|
||||
},
|
||||
}
|
||||
plugin := newMemoryRecallPlugin(memoryService)
|
||||
|
||||
bus := event.NewEventBus()
|
||||
var received types.UsedMemories
|
||||
bus.On(event.EventMemoryRecalled, func(_ context.Context, evt event.Event) error {
|
||||
data, ok := evt.Data.(event.MemoryRecalledData)
|
||||
require.True(t, ok)
|
||||
received, ok = data.Memories.(types.UsedMemories)
|
||||
require.True(t, ok)
|
||||
return nil
|
||||
})
|
||||
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.Query = "继续上次的事"
|
||||
chatManage.EventBus = bus.AsEventBusInterface()
|
||||
|
||||
require.Nil(t, plugin.OnEvent(t.Context(), types.MEMORY_RECALL, chatManage, func() *PluginError { return nil }))
|
||||
|
||||
// The chat UI promises "these are the memories this answer saw", so the
|
||||
// streamed list has to be the same one that was injected.
|
||||
require.Len(t, received, 1)
|
||||
require.Equal(t, "m1", received[0].ID)
|
||||
require.Equal(t, "在做医疗影像", received[0].Content)
|
||||
require.Equal(t, received, chatManage.UsedMemories)
|
||||
}
|
||||
|
||||
func TestMemoryRecallToleratesNoService(t *testing.T) {
|
||||
plugin := newMemoryRecallPlugin(nil)
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.SummaryConfig.Prompt = "你是一个助手。"
|
||||
require.Nil(t, plugin.OnEvent(t.Context(), types.MEMORY_RECALL, chatManage, func() *PluginError { return nil }))
|
||||
require.Empty(t, chatManage.MemoryPrompt)
|
||||
}
|
||||
|
||||
func TestMemoryRecallStageIsRegisteredInThePipeline(t *testing.T) {
|
||||
// A stage nobody runs is the failure mode this whole feature has had
|
||||
// before, so assert the plugin declares the event the assembler adds.
|
||||
plugin := newMemoryRecallPlugin(&stubMemoryService{})
|
||||
require.Equal(t, []types.EventType{types.MEMORY_RECALL}, plugin.ActivationEvents())
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Phase two's claim is that memory changes what gets retrieved, not only what
|
||||
// the answer prompt says. These tests hold the two places that has to be true:
|
||||
// the query the retriever is given, and the order documents come back in.
|
||||
|
||||
type stubRetrievalMemory struct {
|
||||
stubMemoryService
|
||||
|
||||
retrieval interfaces.RetrievalContext
|
||||
affinity map[string]int
|
||||
askedFor []string
|
||||
}
|
||||
|
||||
func (s *stubRetrievalMemory) RetrievalContextFor(context.Context) interfaces.RetrievalContext {
|
||||
return s.retrieval
|
||||
}
|
||||
|
||||
func (s *stubRetrievalMemory) DocumentAffinity(_ context.Context, ids []string) map[string]int {
|
||||
s.askedFor = ids
|
||||
return s.affinity
|
||||
}
|
||||
|
||||
func TestWhoIsAskingReachesTheQueryRewriter(t *testing.T) {
|
||||
memoryService := &stubRetrievalMemory{
|
||||
retrieval: interfaces.RetrievalContext{
|
||||
Background: "在做医学影像的后端",
|
||||
Interests: []string{"医学影像分割"},
|
||||
Documents: []string{"分割模型调参手册"},
|
||||
Items: []*types.MemoryItem{
|
||||
{ID: "m1", Kind: types.MemoryKindProfile, Content: "在做医学影像的后端"},
|
||||
},
|
||||
},
|
||||
}
|
||||
plugin := &PluginQueryUnderstand{
|
||||
memoryService: memoryService,
|
||||
config: &config.Config{Conversation: &config.ConversationConfig{
|
||||
RewritePromptSystem: "改写用户的问题。",
|
||||
RewritePromptUser: "{{query}}",
|
||||
}},
|
||||
}
|
||||
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.Query = "分割怎么调参"
|
||||
|
||||
_, userPrompt := plugin.buildPrompts(t.Context(), chatManage, nil)
|
||||
|
||||
require.Contains(t, userPrompt, "在做医学影像的后端",
|
||||
"the same question means different things to different people, and only "+
|
||||
"the rewriter can act on that before retrieval runs")
|
||||
require.Contains(t, userPrompt, "医学影像分割")
|
||||
require.Contains(t, userPrompt, "分割模型调参手册")
|
||||
require.Contains(t, userPrompt, "分割怎么调参", "the question itself must survive")
|
||||
|
||||
// Conditioning the rewriter is not a recall. The background is fed in
|
||||
// whole, relevant or not, so counting it as "memories this answer used"
|
||||
// would report unrelated memories on every single turn. That list is
|
||||
// MEMORY_RECALL's to build, from what the question actually matched.
|
||||
require.Empty(t, chatManage.UsedMemories)
|
||||
}
|
||||
|
||||
func TestQueryRewriterIsUnchangedWithoutMemory(t *testing.T) {
|
||||
plugin := &PluginQueryUnderstand{
|
||||
memoryService: &stubRetrievalMemory{},
|
||||
config: &config.Config{Conversation: &config.ConversationConfig{
|
||||
RewritePromptSystem: "改写用户的问题。",
|
||||
RewritePromptUser: "{{query}}",
|
||||
}},
|
||||
}
|
||||
chatManage := &types.ChatManage{}
|
||||
chatManage.Query = "分割怎么调参"
|
||||
|
||||
_, userPrompt := plugin.buildPrompts(t.Context(), chatManage, nil)
|
||||
require.NotContains(t, userPrompt, "asker_background")
|
||||
require.Empty(t, chatManage.UsedMemories)
|
||||
}
|
||||
|
||||
func TestFamiliarDocumentsRankHigher(t *testing.T) {
|
||||
memoryService := &stubRetrievalMemory{affinity: map[string]int{"doc-familiar": 8}}
|
||||
plugin := &PluginMemoryAffinity{memoryService: memoryService}
|
||||
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{RerankResult: []*types.SearchResult{
|
||||
{ID: "c1", KnowledgeID: "doc-stranger", Score: 0.80},
|
||||
{ID: "c2", KnowledgeID: "doc-familiar", Score: 0.78},
|
||||
}},
|
||||
}
|
||||
|
||||
err := plugin.OnEvent(t.Context(), types.CHUNK_RERANK, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "c2", chatManage.RerankResult[0].ID,
|
||||
"between two comparable passages, prefer the document this person works from")
|
||||
}
|
||||
|
||||
func TestAnUnrelatedDocumentIsNotDraggedToTheTop(t *testing.T) {
|
||||
// The signal is weak — it says the retriever kept picking a document, not
|
||||
// that the user found it useful — so it must never overturn a clear
|
||||
// relevance gap.
|
||||
memoryService := &stubRetrievalMemory{affinity: map[string]int{"doc-familiar": 1000}}
|
||||
plugin := &PluginMemoryAffinity{memoryService: memoryService}
|
||||
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{RerankResult: []*types.SearchResult{
|
||||
{ID: "c1", KnowledgeID: "doc-relevant", Score: 0.90},
|
||||
{ID: "c2", KnowledgeID: "doc-familiar", Score: 0.40},
|
||||
}},
|
||||
}
|
||||
|
||||
err := plugin.OnEvent(t.Context(), types.CHUNK_RERANK, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "c1", chatManage.RerankResult[0].ID)
|
||||
}
|
||||
|
||||
func TestRerankIsUntouchedWithoutAffinity(t *testing.T) {
|
||||
plugin := &PluginMemoryAffinity{memoryService: &stubRetrievalMemory{}}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{RerankResult: []*types.SearchResult{
|
||||
{ID: "c1", KnowledgeID: "doc-a", Score: 0.80},
|
||||
{ID: "c2", KnowledgeID: "doc-b", Score: 0.78},
|
||||
}},
|
||||
}
|
||||
err := plugin.OnEvent(t.Context(), types.CHUNK_RERANK, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0.80, chatManage.RerankResult[0].Score)
|
||||
require.Equal(t, 0.78, chatManage.RerankResult[1].Score)
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginMerge handles merging of search result chunks
|
||||
type PluginMerge struct {
|
||||
chunkRepo interfaces.ChunkRepository
|
||||
chunkService interfaces.ChunkService // for parent chunk resolution
|
||||
}
|
||||
|
||||
// NewPluginMerge creates and registers a new PluginMerge instance
|
||||
func NewPluginMerge(eventManager *EventManager, chunkRepo interfaces.ChunkRepository, chunkService interfaces.ChunkService) *PluginMerge {
|
||||
res := &PluginMerge{
|
||||
chunkRepo: chunkRepo,
|
||||
chunkService: chunkService,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginMerge) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHUNK_MERGE}
|
||||
}
|
||||
|
||||
// OnEvent processes the CHUNK_MERGE event to merge search result chunks.
|
||||
// The merge pipeline is:
|
||||
// 1. Select input (rerank or search fallback)
|
||||
// 2. Deduplicate by ID and content signature
|
||||
// 3. Inject relevant history references
|
||||
// 4. Resolve parent chunks (child → parent content)
|
||||
// 5. Group by knowledge source + chunk type, merge sequential current bodies
|
||||
// 6. Populate FAQ answers
|
||||
// 7. Expand short contexts with neighboring chunks
|
||||
// 7.5. Re-merge sequential or contained bodies introduced by expansion
|
||||
// 8. Final deduplication (ID + signature + partial content overlap)
|
||||
func (p *PluginMerge) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
return next()
|
||||
}
|
||||
pipelineInfo(ctx, "Merge", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"candidate_cnt": len(chatManage.RerankResult),
|
||||
})
|
||||
|
||||
// Step 1: Select input
|
||||
searchResult := p.selectInputResults(ctx, chatManage)
|
||||
|
||||
// Step 2: Initial dedup
|
||||
searchResult = p.dedup(ctx, "dedup_summary", searchResult)
|
||||
|
||||
// Step 3: Inject history references
|
||||
searchResult = p.injectHistoryResults(ctx, chatManage, searchResult)
|
||||
|
||||
pipelineInfo(ctx, "Merge", "candidate_ready", map[string]interface{}{
|
||||
"chunk_cnt": len(searchResult),
|
||||
})
|
||||
|
||||
if len(searchResult) == 0 {
|
||||
pipelineWarn(ctx, "Merge", "output", map[string]interface{}{
|
||||
"chunk_cnt": 0,
|
||||
"reason": "no_candidates",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
// Step 4: Resolve parent chunks
|
||||
searchResult = p.resolveParentChunks(ctx, chatManage, searchResult)
|
||||
|
||||
// Step 5: Group by knowledge/chunkType and merge overlapping ranges
|
||||
mergedChunks := p.groupAndMergeCurrentContent(ctx, searchResult)
|
||||
|
||||
// Step 6: Populate FAQ answers
|
||||
mergedChunks = p.populateFAQAnswers(ctx, chatManage, mergedChunks)
|
||||
|
||||
// Step 7: Expand short contexts
|
||||
mergedChunks = p.expandShortContextWithNeighbors(ctx, chatManage, mergedChunks)
|
||||
|
||||
// Step 7.5: Re-merge overlapping ranges introduced by expansion
|
||||
mergedChunks = p.groupAndMergeCurrentContent(ctx, mergedChunks)
|
||||
|
||||
// Step 8: Final dedup — catches exact duplicates plus partial content overlaps
|
||||
mergedChunks = p.dedup(ctx, "final_dedup", mergedChunks)
|
||||
mergedChunks = removePartialOverlaps(ctx, mergedChunks)
|
||||
|
||||
chatManage.MergeResult = mergedChunks
|
||||
return next()
|
||||
}
|
||||
|
||||
// selectInputResults picks rerank results if available, falling back to search
|
||||
// results sorted by score descending.
|
||||
func (p *PluginMerge) selectInputResults(ctx context.Context, chatManage *types.ChatManage) []*types.SearchResult {
|
||||
if len(chatManage.RerankResult) > 0 {
|
||||
return chatManage.RerankResult
|
||||
}
|
||||
pipelineWarn(ctx, "Merge", "fallback", map[string]interface{}{
|
||||
"reason": "empty_rerank_result",
|
||||
})
|
||||
result := chatManage.SearchResult
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Score > result[j].Score
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// dedup wraps removeDuplicateResults with before/after logging.
|
||||
func (p *PluginMerge) dedup(ctx context.Context, label string, results []*types.SearchResult) []*types.SearchResult {
|
||||
before := len(results)
|
||||
out := removeDuplicateResults(results)
|
||||
if len(out) < before {
|
||||
pipelineInfo(ctx, "Merge", label, map[string]interface{}{
|
||||
"before": before,
|
||||
"after": len(out),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// injectHistoryResults appends relevant history references to the current results
|
||||
// and deduplicates the combined set.
|
||||
func (p *PluginMerge) injectHistoryResults(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
current []*types.SearchResult,
|
||||
) []*types.SearchResult {
|
||||
historyResults := filterHistoryResults(ctx, chatManage, current)
|
||||
if len(historyResults) == 0 {
|
||||
return current
|
||||
}
|
||||
pipelineInfo(ctx, "Merge", "history_inject", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"history_hits": len(historyResults),
|
||||
})
|
||||
combined := append(current, historyResults...)
|
||||
return removeDuplicateResults(combined)
|
||||
}
|
||||
|
||||
// groupAndMergeCurrentContent groups chunks by KnowledgeID + ChunkType, then joins
|
||||
// sequential current bodies without consulting source character offsets.
|
||||
func (p *PluginMerge) groupAndMergeCurrentContent(ctx context.Context, results []*types.SearchResult) []*types.SearchResult {
|
||||
// Group by KnowledgeID → ChunkType
|
||||
knowledgeGroup := make(map[string]map[string][]*types.SearchResult)
|
||||
for _, chunk := range results {
|
||||
if _, ok := knowledgeGroup[chunk.KnowledgeID]; !ok {
|
||||
knowledgeGroup[chunk.KnowledgeID] = make(map[string][]*types.SearchResult)
|
||||
}
|
||||
knowledgeGroup[chunk.KnowledgeID][chunk.ChunkType] = append(
|
||||
knowledgeGroup[chunk.KnowledgeID][chunk.ChunkType], chunk,
|
||||
)
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Merge", "group_summary", map[string]interface{}{
|
||||
"knowledge_cnt": len(knowledgeGroup),
|
||||
})
|
||||
|
||||
// Flatten into independent (knowledgeID, chunks) work units for parallel merge.
|
||||
type mergeUnit struct {
|
||||
knowledgeID string
|
||||
chunks []*types.SearchResult
|
||||
}
|
||||
var units []mergeUnit
|
||||
for knowledgeID, chunkGroup := range knowledgeGroup {
|
||||
for _, chunks := range chunkGroup {
|
||||
units = append(units, mergeUnit{knowledgeID: knowledgeID, chunks: chunks})
|
||||
}
|
||||
}
|
||||
|
||||
groupResults := ParallelMap(units, 0, func(_ int, u mergeUnit) []*types.SearchResult {
|
||||
pipelineInfo(ctx, "Merge", "group_process", map[string]interface{}{
|
||||
"knowledge_id": u.knowledgeID,
|
||||
"chunk_cnt": len(u.chunks),
|
||||
})
|
||||
|
||||
sort.Slice(u.chunks, func(i, j int) bool {
|
||||
if u.chunks[i].ChunkIndex == u.chunks[j].ChunkIndex {
|
||||
return u.chunks[i].ID < u.chunks[j].ID
|
||||
}
|
||||
return u.chunks[i].ChunkIndex < u.chunks[j].ChunkIndex
|
||||
})
|
||||
|
||||
grouped := p.mergeSequentialChunks(ctx, u.knowledgeID, u.chunks)
|
||||
|
||||
pipelineInfo(ctx, "Merge", "group_output", map[string]interface{}{
|
||||
"knowledge_id": u.knowledgeID,
|
||||
"merged_chunks": len(grouped),
|
||||
})
|
||||
return grouped
|
||||
})
|
||||
|
||||
var mergedChunks []*types.SearchResult
|
||||
for _, g := range groupResults {
|
||||
mergedChunks = append(mergedChunks, g...)
|
||||
}
|
||||
|
||||
// Global sort restores relevance order after map-based grouping.
|
||||
// n is typically < 100, O(n log n) is negligible here.
|
||||
sortSearchResultsDeterministically(mergedChunks)
|
||||
|
||||
pipelineInfo(ctx, "Merge", "output", map[string]interface{}{
|
||||
"merged_total": len(mergedChunks),
|
||||
})
|
||||
return mergedChunks
|
||||
}
|
||||
|
||||
// resolveParentChunks expands parent-child retrieval results with current
|
||||
// parent_text context. Text children and image grandchildren share the same
|
||||
// behavior, while image Markdown is scoped to the matched text child by
|
||||
// durable URLs rather than parser coordinates.
|
||||
func (p *PluginMerge) resolveParentChunks(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
results []*types.SearchResult,
|
||||
) []*types.SearchResult {
|
||||
if len(results) == 0 || p.chunkRepo == nil {
|
||||
return results
|
||||
}
|
||||
|
||||
tenantID, _ := types.TenantIDFromContext(ctx)
|
||||
if tenantID == 0 && chatManage != nil {
|
||||
tenantID = chatManage.TenantID
|
||||
}
|
||||
if tenantID == 0 {
|
||||
pipelineWarn(ctx, "Merge", "parent_resolve_skip", map[string]interface{}{
|
||||
"reason": "missing_tenant",
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
// Collect unique parent chunk IDs
|
||||
parentIDs := make(map[string]struct{})
|
||||
for _, r := range results {
|
||||
if r.ParentChunkID != "" {
|
||||
parentIDs[r.ParentChunkID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if len(parentIDs) == 0 {
|
||||
return results
|
||||
}
|
||||
|
||||
// Batch fetch parent chunks
|
||||
ids := make([]string, 0, len(parentIDs))
|
||||
for id := range parentIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
parentChunks, err := p.chunkRepo.ListChunksByID(ctx, tenantID, ids)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "parent_resolve_failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
parentMap := make(map[string]*types.Chunk, len(parentChunks))
|
||||
for _, c := range parentChunks {
|
||||
parentMap[c.ID] = c
|
||||
}
|
||||
|
||||
// Image hits have an image -> text -> parent_text chain. Fetch the
|
||||
// grandparent only for those results so they retain parent-child context
|
||||
// without using editable StartAt/EndAt coordinates.
|
||||
imageTextParentIDs := make(map[string]struct{})
|
||||
for _, r := range results {
|
||||
if r.ChunkType == string(types.ChunkTypeImageOCR) || r.ChunkType == string(types.ChunkTypeImageCaption) {
|
||||
imageTextParentIDs[r.ParentChunkID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(imageTextParentIDs) > 0 {
|
||||
grandparentIDs := make([]string, 0)
|
||||
grandparentSeen := make(map[string]struct{})
|
||||
for _, parent := range parentChunks {
|
||||
if _, needed := imageTextParentIDs[parent.ID]; !needed {
|
||||
continue
|
||||
}
|
||||
if parent.ParentChunkID == "" || parent.ChunkType != types.ChunkTypeText {
|
||||
continue
|
||||
}
|
||||
if _, already := parentMap[parent.ParentChunkID]; already {
|
||||
continue
|
||||
}
|
||||
if _, already := grandparentSeen[parent.ParentChunkID]; already {
|
||||
continue
|
||||
}
|
||||
grandparentSeen[parent.ParentChunkID] = struct{}{}
|
||||
grandparentIDs = append(grandparentIDs, parent.ParentChunkID)
|
||||
}
|
||||
if len(grandparentIDs) > 0 {
|
||||
grandparents, fetchErr := p.chunkRepo.ListChunksByID(ctx, tenantID, grandparentIDs)
|
||||
if fetchErr != nil {
|
||||
pipelineWarn(ctx, "Merge", "grandparent_fetch_failed", map[string]interface{}{
|
||||
"error": fetchErr.Error(),
|
||||
})
|
||||
} else {
|
||||
for _, grandparent := range grandparents {
|
||||
parentMap[grandparent.ID] = grandparent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-fetch image_info scoped to matched text children only.
|
||||
textChildIDs := collectScopedTextChildIDs(results, parentMap)
|
||||
var scopedImageInfo map[string]string
|
||||
if len(textChildIDs) > 0 {
|
||||
scopedImageInfo = searchutil.CollectImageInfoByChunkIDs(ctx, p.chunkRepo, tenantID, textChildIDs)
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
if r.ParentChunkID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch r.ChunkType {
|
||||
case string(types.ChunkTypeText):
|
||||
// text → parent_text: expand to full parent for surrounding context
|
||||
// (the core parent-child value). Scope ImageInfo to this child only so
|
||||
// image-heavy parents do not inject every sibling page's OCR/Caption.
|
||||
parent, ok := parentMap[r.ParentChunkID]
|
||||
if !ok || parent.Content == "" || parent.ChunkType != types.ChunkTypeParentText {
|
||||
continue
|
||||
}
|
||||
pipelineInfo(ctx, "Merge", "parent_resolve", map[string]interface{}{
|
||||
"child_id": r.ID,
|
||||
"parent_id": r.ParentChunkID,
|
||||
"child_len": runeLen(r.Content),
|
||||
"parent_len": runeLen(parent.Content),
|
||||
"scoped_img": true,
|
||||
})
|
||||
assignScopedImageInfo(r, scopedImageInfo, r.ID)
|
||||
parentContent := searchutil.PruneMarkdownImagesByImageInfo(parent.Content, r.ImageInfo)
|
||||
r.Content = searchutil.JoinChunkContent(parentContent, r.Content, "\n\n")
|
||||
r.ContentRewritten = true
|
||||
if !containsID(r.SubChunkID, r.ID) {
|
||||
r.SubChunkID = append(r.SubChunkID, r.ID)
|
||||
}
|
||||
|
||||
case string(types.ChunkTypeImageOCR), string(types.ChunkTypeImageCaption):
|
||||
textParent, ok := parentMap[r.ParentChunkID]
|
||||
if !ok || textParent.Content == "" || textParent.ChunkType != types.ChunkTypeText {
|
||||
continue
|
||||
}
|
||||
hitImageInfo := r.ImageInfo
|
||||
contentSource := textParent
|
||||
if textParent.ParentChunkID != "" {
|
||||
if grandparent, found := parentMap[textParent.ParentChunkID]; found &&
|
||||
grandparent.ChunkType == types.ChunkTypeParentText && grandparent.Content != "" {
|
||||
contentSource = grandparent
|
||||
}
|
||||
}
|
||||
r.Content = textParent.Content
|
||||
r.ChunkIndex = textParent.ChunkIndex
|
||||
r.ContentRewritten = true
|
||||
assignScopedImageInfo(r, scopedImageInfo, textParent.ID)
|
||||
if r.ImageInfo == "" && hitImageInfo != "" {
|
||||
r.ImageInfo = searchutil.FilterImageInfoByContentURLs(textParent.Content, hitImageInfo)
|
||||
}
|
||||
textContent := searchutil.PruneMarkdownImagesByImageInfo(textParent.Content, r.ImageInfo)
|
||||
parentContent := searchutil.PruneMarkdownImagesByImageInfo(contentSource.Content, r.ImageInfo)
|
||||
r.Content = searchutil.JoinChunkContent(parentContent, textContent, "\n\n")
|
||||
r.ContentRewritten = true
|
||||
pipelineInfo(ctx, "Merge", "image_parent_resolve", map[string]interface{}{
|
||||
"child_id": r.ID,
|
||||
"child_type": r.ChunkType,
|
||||
"text_id": textParent.ID,
|
||||
"parent_id": contentSource.ID,
|
||||
"match_len": runeLen(r.Content),
|
||||
"parent_len": runeLen(contentSource.Content),
|
||||
"scoped": true,
|
||||
})
|
||||
if !containsID(r.SubChunkID, r.ID) {
|
||||
r.SubChunkID = append(r.SubChunkID, r.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// collectScopedTextChildIDs returns text chunk IDs whose image_info should be
|
||||
// loaded for parent-child merge scoping.
|
||||
func collectScopedTextChildIDs(
|
||||
results []*types.SearchResult,
|
||||
parentMap map[string]*types.Chunk,
|
||||
) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var ids []string
|
||||
for _, r := range results {
|
||||
if r.ParentChunkID == "" {
|
||||
continue
|
||||
}
|
||||
switch r.ChunkType {
|
||||
case string(types.ChunkTypeText):
|
||||
parent := parentMap[r.ParentChunkID]
|
||||
if parent == nil || parent.ChunkType != types.ChunkTypeParentText {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[r.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[r.ID] = struct{}{}
|
||||
ids = append(ids, r.ID)
|
||||
case string(types.ChunkTypeImageOCR), string(types.ChunkTypeImageCaption):
|
||||
if _, ok := seen[r.ParentChunkID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[r.ParentChunkID] = struct{}{}
|
||||
ids = append(ids, r.ParentChunkID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// assignScopedImageInfo sets ImageInfo from the per-text-child map, falling
|
||||
// back to URLs present in the result content.
|
||||
func assignScopedImageInfo(r *types.SearchResult, scoped map[string]string, textChildID string) {
|
||||
if scoped != nil {
|
||||
if info, ok := scoped[textChildID]; ok && info != "" {
|
||||
r.ImageInfo = info
|
||||
return
|
||||
}
|
||||
}
|
||||
if r.ImageInfo != "" {
|
||||
r.ImageInfo = searchutil.FilterImageInfoByContentURLs(r.Content, r.ImageInfo)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// expandShortContextWithNeighbors expands the short context with neighbors
|
||||
func (p *PluginMerge) expandShortContextWithNeighbors(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
results []*types.SearchResult,
|
||||
) []*types.SearchResult {
|
||||
const (
|
||||
minLen = 350
|
||||
maxLen = 850
|
||||
)
|
||||
|
||||
if len(results) == 0 || p.chunkRepo == nil {
|
||||
return results
|
||||
}
|
||||
|
||||
tenantID, _ := types.TenantIDFromContext(ctx)
|
||||
if tenantID == 0 && chatManage != nil {
|
||||
tenantID = chatManage.TenantID
|
||||
}
|
||||
if tenantID == 0 {
|
||||
pipelineWarn(ctx, "Merge", "expand_skip", map[string]interface{}{
|
||||
"reason": "missing_tenant",
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
type targetInfo struct {
|
||||
result *types.SearchResult
|
||||
}
|
||||
|
||||
targets := make([]targetInfo, 0)
|
||||
baseIDsSet := make(map[string]struct{})
|
||||
|
||||
for _, r := range results {
|
||||
if r == nil || r.ID == "" || r.Content == "" {
|
||||
continue
|
||||
}
|
||||
if r.ChunkType != string(types.ChunkTypeText) {
|
||||
continue
|
||||
}
|
||||
if runeLen(r.Content) >= minLen {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, targetInfo{result: r})
|
||||
baseIDsSet[r.ID] = struct{}{}
|
||||
pipelineInfo(ctx, "Merge", "need_expand", map[string]interface{}{
|
||||
"chunk_id": r.ID,
|
||||
"content": r.Content,
|
||||
"chunk_type": r.ChunkType,
|
||||
"len": runeLen(r.Content),
|
||||
})
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
return results
|
||||
}
|
||||
|
||||
baseIDs := make([]string, 0, len(baseIDsSet))
|
||||
for id := range baseIDsSet {
|
||||
baseIDs = append(baseIDs, id)
|
||||
}
|
||||
|
||||
chunkMap := make(map[string]*types.Chunk, len(baseIDs))
|
||||
chunks, err := p.chunkRepo.ListChunksByID(ctx, tenantID, baseIDs)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "expand_list_base_failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return results
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
chunkMap[chunk.ID] = chunk
|
||||
}
|
||||
|
||||
neighborIDsSet := make(map[string]struct{})
|
||||
for _, chunk := range chunkMap {
|
||||
if chunk == nil {
|
||||
continue
|
||||
}
|
||||
if chunk.PreChunkID != "" {
|
||||
if _, exists := chunkMap[chunk.PreChunkID]; !exists {
|
||||
neighborIDsSet[chunk.PreChunkID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if chunk.NextChunkID != "" {
|
||||
if _, exists := chunkMap[chunk.NextChunkID]; !exists {
|
||||
neighborIDsSet[chunk.NextChunkID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(neighborIDsSet) > 0 {
|
||||
neighborIDs := make([]string, 0, len(neighborIDsSet))
|
||||
for id := range neighborIDsSet {
|
||||
neighborIDs = append(neighborIDs, id)
|
||||
}
|
||||
neighbors, err := p.chunkRepo.ListChunksByID(ctx, tenantID, neighborIDs)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "expand_list_neighbor_failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
for _, chunk := range neighbors {
|
||||
chunkMap[chunk.ID] = chunk
|
||||
pipelineInfo(ctx, "Merge", "expand_list_neighbor_success", map[string]interface{}{
|
||||
"neighbor_chunk_id": chunk.ID,
|
||||
"neighbor_content": chunk.Content,
|
||||
"neighbor_chunk_type": chunk.ChunkType,
|
||||
"neighbor_len": runeLen(chunk.Content),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
res := target.result
|
||||
p.fetchChunksIfMissing(ctx, tenantID, chunkMap, res.ID)
|
||||
baseChunk := chunkMap[res.ID]
|
||||
if baseChunk == nil || baseChunk.Content == "" || baseChunk.ChunkType != types.ChunkTypeText {
|
||||
continue
|
||||
}
|
||||
|
||||
prevContent := ""
|
||||
nextContent := ""
|
||||
prevIDs := []string{}
|
||||
nextIDs := []string{}
|
||||
|
||||
prevCursor := baseChunk.PreChunkID
|
||||
nextCursor := baseChunk.NextChunkID
|
||||
|
||||
p.fetchChunksIfMissing(ctx, tenantID, chunkMap, prevCursor, nextCursor)
|
||||
|
||||
if prevCursor != "" {
|
||||
if prevChunk := chunkMap[prevCursor]; prevChunk != nil && prevChunk.KnowledgeID == baseChunk.KnowledgeID {
|
||||
prevContent = prevChunk.Content
|
||||
prevIDs = append(prevIDs, prevChunk.ID)
|
||||
prevCursor = prevChunk.PreChunkID
|
||||
} else {
|
||||
prevCursor = ""
|
||||
}
|
||||
}
|
||||
|
||||
if nextCursor != "" {
|
||||
if nextChunk := chunkMap[nextCursor]; nextChunk != nil && nextChunk.KnowledgeID == baseChunk.KnowledgeID {
|
||||
nextContent = nextChunk.Content
|
||||
nextIDs = append(nextIDs, nextChunk.ID)
|
||||
nextCursor = nextChunk.NextChunkID
|
||||
} else {
|
||||
nextCursor = ""
|
||||
}
|
||||
}
|
||||
|
||||
var merged string
|
||||
for {
|
||||
merged = mergeOrderedContent(prevContent, baseChunk.Content, nextContent, maxLen)
|
||||
if merged == "" {
|
||||
break
|
||||
}
|
||||
if runeLen(merged) >= minLen {
|
||||
break
|
||||
}
|
||||
if prevCursor == "" && nextCursor == "" {
|
||||
break
|
||||
}
|
||||
|
||||
expanded := false
|
||||
if prevCursor != "" {
|
||||
p.fetchChunksIfMissing(ctx, tenantID, chunkMap, prevCursor)
|
||||
if prevChunk := chunkMap[prevCursor]; prevChunk != nil &&
|
||||
prevChunk.KnowledgeID == baseChunk.KnowledgeID {
|
||||
prevContent = searchutil.JoinChunkContent(prevChunk.Content, prevContent, "\n\n")
|
||||
prevIDs = append([]string{prevChunk.ID}, prevIDs...)
|
||||
prevCursor = prevChunk.PreChunkID
|
||||
expanded = true
|
||||
} else {
|
||||
prevCursor = ""
|
||||
}
|
||||
}
|
||||
|
||||
merged = mergeOrderedContent(prevContent, baseChunk.Content, nextContent, maxLen)
|
||||
if runeLen(merged) >= minLen {
|
||||
break
|
||||
}
|
||||
|
||||
if nextCursor != "" {
|
||||
p.fetchChunksIfMissing(ctx, tenantID, chunkMap, nextCursor)
|
||||
if nextChunk := chunkMap[nextCursor]; nextChunk != nil &&
|
||||
nextChunk.KnowledgeID == baseChunk.KnowledgeID {
|
||||
nextContent = searchutil.JoinChunkContent(nextContent, nextChunk.Content, "\n\n")
|
||||
nextIDs = append(nextIDs, nextChunk.ID)
|
||||
nextCursor = nextChunk.NextChunkID
|
||||
expanded = true
|
||||
} else {
|
||||
nextCursor = ""
|
||||
}
|
||||
}
|
||||
|
||||
if !expanded {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if merged == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
beforeLen := runeLen(res.Content)
|
||||
res.Content = merged
|
||||
res.ContentRewritten = true
|
||||
|
||||
for _, id := range prevIDs {
|
||||
if id != "" && !containsID(res.SubChunkID, id) {
|
||||
res.SubChunkID = append(res.SubChunkID, id)
|
||||
}
|
||||
}
|
||||
for _, id := range nextIDs {
|
||||
if id != "" && !containsID(res.SubChunkID, id) {
|
||||
res.SubChunkID = append(res.SubChunkID, id)
|
||||
}
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Merge", "expand_short_chunk", map[string]interface{}{
|
||||
"chunk_id": res.ID,
|
||||
"prev_ids": prevIDs,
|
||||
"next_ids": nextIDs,
|
||||
"before_len": beforeLen,
|
||||
"after_len": runeLen(res.Content),
|
||||
"base_content": baseChunk.Content,
|
||||
"after_content": res.Content,
|
||||
"chunk_type": res.ChunkType,
|
||||
"remaining_prev": prevCursor,
|
||||
"remaining_next": nextCursor,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// runeLen returns the length of a string in runes
|
||||
func runeLen(s string) int {
|
||||
return len([]rune(s))
|
||||
}
|
||||
|
||||
// mergeOrderedContent merges ordered content
|
||||
func mergeOrderedContent(prev, base, next string, maxLen int) string {
|
||||
content := base
|
||||
if prev != "" {
|
||||
content = searchutil.JoinChunkContent(prev, content, "\n\n")
|
||||
}
|
||||
if next != "" {
|
||||
content = searchutil.JoinChunkContent(content, next, "\n\n")
|
||||
}
|
||||
runes := []rune(content)
|
||||
if len(runes) > maxLen {
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func containsID(ids []string, target string) bool {
|
||||
for _, id := range ids {
|
||||
if id == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *PluginMerge) fetchChunksIfMissing(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
chunkMap map[string]*types.Chunk,
|
||||
chunkIDs ...string,
|
||||
) {
|
||||
missing := make([]string, 0, len(chunkIDs))
|
||||
for _, id := range chunkIDs {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := chunkMap[id]; !exists {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
chunks, err := p.chunkRepo.ListChunksByID(ctx, tenantID, missing)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "expand_fetch_missing_failed", map[string]interface{}{
|
||||
"missing_cnt": len(missing),
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
found := make(map[string]struct{}, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
chunkMap[chunk.ID] = chunk
|
||||
found[chunk.ID] = struct{}{}
|
||||
}
|
||||
|
||||
for _, id := range missing {
|
||||
if _, ok := found[id]; !ok {
|
||||
chunkMap[id] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
type expandChunkRepo struct {
|
||||
interfaces.ChunkRepository
|
||||
chunks map[string]*types.Chunk
|
||||
children map[string][]*types.Chunk
|
||||
}
|
||||
|
||||
func (r *expandChunkRepo) ListChunksByParentIDs(
|
||||
_ context.Context, _ uint64, parentIDs []string,
|
||||
) ([]*types.Chunk, error) {
|
||||
var chunks []*types.Chunk
|
||||
for _, parentID := range parentIDs {
|
||||
chunks = append(chunks, r.children[parentID]...)
|
||||
}
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func (r *expandChunkRepo) ListChunksByID(
|
||||
_ context.Context, _ uint64, ids []string,
|
||||
) ([]*types.Chunk, error) {
|
||||
chunks := make([]*types.Chunk, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if chunk := r.chunks[id]; chunk != nil {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
}
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func TestExpandShortContextKeepsSourceCoordinates(t *testing.T) {
|
||||
repo := &expandChunkRepo{chunks: map[string]*types.Chunk{
|
||||
"prev": {ID: "prev", KnowledgeID: "doc", ChunkType: types.ChunkTypeText, Content: "edited previous body", NextChunkID: "base"},
|
||||
"base": {ID: "base", KnowledgeID: "doc", ChunkType: types.ChunkTypeText, Content: "edited base body", PreChunkID: "prev", NextChunkID: "next", StartAt: 100, EndAt: 120},
|
||||
"next": {ID: "next", KnowledgeID: "doc", ChunkType: types.ChunkTypeText, Content: "edited next body", PreChunkID: "base"},
|
||||
}}
|
||||
plugin := &PluginMerge{chunkRepo: repo}
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
|
||||
result := &types.SearchResult{
|
||||
ID: "base", KnowledgeID: "doc", ChunkType: string(types.ChunkTypeText),
|
||||
Content: "edited base body", StartAt: 100, EndAt: 120,
|
||||
}
|
||||
|
||||
got := plugin.expandShortContextWithNeighbors(ctx, &types.ChatManage{}, []*types.SearchResult{result})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("result count = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].StartAt != 100 || got[0].EndAt != 120 {
|
||||
t.Fatalf("source coordinates changed to [%d,%d)", got[0].StartAt, got[0].EndAt)
|
||||
}
|
||||
for _, want := range []string{"edited previous body", "edited base body", "edited next body"} {
|
||||
if !strings.Contains(got[0].Content, want) {
|
||||
t.Fatalf("expanded content missing %q: %q", want, got[0].Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// populateFAQAnswers populates FAQ answers for the search results
|
||||
func (p *PluginMerge) populateFAQAnswers(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
results []*types.SearchResult,
|
||||
) []*types.SearchResult {
|
||||
if len(results) == 0 || p.chunkRepo == nil {
|
||||
return results
|
||||
}
|
||||
|
||||
tenantID, _ := types.TenantIDFromContext(ctx)
|
||||
if tenantID == 0 && chatManage != nil {
|
||||
tenantID = chatManage.TenantID
|
||||
}
|
||||
if tenantID == 0 {
|
||||
pipelineWarn(ctx, "Merge", "faq_enrich_skip", map[string]interface{}{
|
||||
"reason": "missing_tenant",
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
chunkResultMap := make(map[string][]*types.SearchResult)
|
||||
chunkIDSet := make(map[string]struct{})
|
||||
for _, r := range results {
|
||||
if r == nil || r.ID == "" {
|
||||
continue
|
||||
}
|
||||
if r.ChunkType != string(types.ChunkTypeFAQ) {
|
||||
continue
|
||||
}
|
||||
chunkResultMap[r.ID] = append(chunkResultMap[r.ID], r)
|
||||
if _, exists := chunkIDSet[r.ID]; !exists {
|
||||
chunkIDSet[r.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if len(chunkIDSet) == 0 {
|
||||
return results
|
||||
}
|
||||
|
||||
chunkIDs := make([]string, 0, len(chunkIDSet))
|
||||
for id := range chunkIDSet {
|
||||
chunkIDs = append(chunkIDs, id)
|
||||
}
|
||||
|
||||
chunks, err := p.chunkRepo.ListChunksByID(ctx, tenantID, chunkIDs)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "faq_chunk_fetch_failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
updated := 0
|
||||
for _, chunk := range chunks {
|
||||
if chunk == nil {
|
||||
continue
|
||||
}
|
||||
meta, err := chunk.FAQMetadata()
|
||||
if err != nil || meta == nil {
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "faq_metadata_parse_failed", map[string]interface{}{
|
||||
"chunk_id": chunk.ID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
content := buildFAQAnswerContent(meta)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
for _, r := range chunkResultMap[chunk.ID] {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
r.Content = content
|
||||
updated++
|
||||
}
|
||||
}
|
||||
|
||||
if updated > 0 {
|
||||
pipelineInfo(ctx, "Merge", "faq_content_enriched", map[string]interface{}{
|
||||
"chunk_cnt": updated,
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// buildFAQAnswerContent builds the content of a FAQ answer
|
||||
func buildFAQAnswerContent(meta *types.FAQChunkMetadata) string {
|
||||
if meta == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
question := strings.TrimSpace(meta.StandardQuestion)
|
||||
answers := make([]string, 0, len(meta.Answers))
|
||||
for _, ans := range meta.Answers {
|
||||
if trimmed := strings.TrimSpace(ans); trimmed != "" {
|
||||
answers = append(answers, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
if question == "" && len(answers) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
if question != "" {
|
||||
builder.WriteString("Q: ")
|
||||
builder.WriteString(question)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(answers) > 0 {
|
||||
builder.WriteString("Answer:\n")
|
||||
for _, ans := range answers {
|
||||
builder.WriteString("- ")
|
||||
builder.WriteString(ans)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// groupAndMergeCurrentContent groups results by KnowledgeID and ChunkType
|
||||
// using two levels of Go maps. Map iteration order is non-deterministic,
|
||||
// so the concatenated output can appear in arbitrary order. This test
|
||||
// verifies that the post-merge sort restores deterministic ordering
|
||||
// (score desc, then KnowledgeID, ChunkType).
|
||||
func TestGroupAndMergeOverlapping_DeterministicOrdering(t *testing.T) {
|
||||
plugin := &PluginMerge{}
|
||||
|
||||
t.Run("sorts by score descending across groups", func(t *testing.T) {
|
||||
// Same KnowledgeID, different ChunkTypes -> separate inner-map
|
||||
// buckets whose iteration order is randomized.
|
||||
chunks := []*types.SearchResult{
|
||||
{ID: "low", KnowledgeID: "kb-001", ChunkType: "text", StartAt: 0, EndAt: 100, Score: 0.4},
|
||||
{ID: "high", KnowledgeID: "kb-001", ChunkType: "summary", StartAt: 200, EndAt: 300, Score: 0.9},
|
||||
{ID: "mid", KnowledgeID: "kb-001", ChunkType: "parent_text", StartAt: 400, EndAt: 500, Score: 0.6},
|
||||
}
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
|
||||
require.Len(t, results, 3)
|
||||
assert.Equal(t, "high", results[0].ID, "highest score (0.9) must be first")
|
||||
assert.Equal(t, "mid", results[1].ID, "middle score (0.6) must be second")
|
||||
assert.Equal(t, "low", results[2].ID, "lowest score (0.4) must be last")
|
||||
})
|
||||
|
||||
t.Run("uses KnowledgeID as first tie-breaker when scores equal", func(t *testing.T) {
|
||||
// Different KnowledgeIDs -> separate outer-map buckets whose
|
||||
// iteration order is randomized.
|
||||
chunks := []*types.SearchResult{
|
||||
{ID: "ab", KnowledgeID: "kb-ab", ChunkType: "text", StartAt: 0, EndAt: 50, Score: 0.8},
|
||||
{ID: "aa", KnowledgeID: "kb-aa", ChunkType: "text", StartAt: 0, EndAt: 50, Score: 0.8},
|
||||
}
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
require.Len(t, results, 2)
|
||||
assert.Equal(t, "aa", results[0].ID, "same score, kb-aa < kb-ab")
|
||||
})
|
||||
|
||||
t.Run("merged chunk uses max score and is sorted among other groups", func(t *testing.T) {
|
||||
// Two overlapping text chunks merge into one (score = max(0.5,0.7) = 0.7),
|
||||
// then the merged result competes with a higher-scored summary chunk
|
||||
// across the inner-map boundary.
|
||||
chunks := []*types.SearchResult{
|
||||
{ID: "low-a", KnowledgeID: "kb-001", ChunkType: "text", ChunkIndex: 1, Content: "first body", StartAt: 0, EndAt: 50, Score: 0.5},
|
||||
{ID: "low-b", KnowledgeID: "kb-001", ChunkType: "text", ChunkIndex: 2, Content: "second body", StartAt: 30, EndAt: 80, Score: 0.7},
|
||||
{ID: "high", KnowledgeID: "kb-001", ChunkType: "summary", StartAt: 200, EndAt: 300, Score: 0.9},
|
||||
}
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
require.Len(t, results, 2, "two text chunks merged into one, plus one summary")
|
||||
assert.Equal(t, "high", results[0].ID, "summary (0.9) beats merged text (0.7)")
|
||||
})
|
||||
|
||||
t.Run("uses ChunkType as tie-breaker when score and knowledge match", func(t *testing.T) {
|
||||
// Same KnowledgeID, different ChunkTypes -> separate inner-map
|
||||
// buckets whose iteration order is randomized.
|
||||
chunks := []*types.SearchResult{
|
||||
{ID: "txt", KnowledgeID: "kb-x", ChunkType: "text", StartAt: 0, EndAt: 50, Score: 0.8},
|
||||
{ID: "sum", KnowledgeID: "kb-x", ChunkType: "summary", StartAt: 100, EndAt: 150, Score: 0.8},
|
||||
}
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
require.Len(t, results, 2)
|
||||
assert.Equal(t, "sum", results[0].ID, "same kb+score, 'summary' < 'text'")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGroupAndMergeOverlapping_IgnoresStaleEditedRanges(t *testing.T) {
|
||||
plugin := &PluginMerge{}
|
||||
chunks := []*types.SearchResult{
|
||||
{
|
||||
ID: "edited-outer", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 1,
|
||||
StartAt: 0, EndAt: 200, Content: "completely rewritten outer content", Score: 0.4,
|
||||
},
|
||||
{
|
||||
ID: "edited-inner", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 2,
|
||||
StartAt: 50, EndAt: 100, Content: "independent current inner content", Score: 0.9,
|
||||
},
|
||||
}
|
||||
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, chunks[0].Content)
|
||||
assert.Contains(t, results[0].Content, chunks[1].Content)
|
||||
assert.Equal(t, 0.9, results[0].Score)
|
||||
}
|
||||
|
||||
func TestGroupAndMergeOverlapping_DoesNotMergeNonSequentialChunksFromCoordinates(t *testing.T) {
|
||||
plugin := &PluginMerge{}
|
||||
chunks := []*types.SearchResult{
|
||||
{ID: "one", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 1, StartAt: 0, EndAt: 200, Content: "one"},
|
||||
{ID: "three", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 3, StartAt: 50, EndAt: 100, Content: "three"},
|
||||
}
|
||||
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
require.Len(t, results, 2)
|
||||
}
|
||||
|
||||
// TestGroupAndMergeOverlapping_CrossKnowledgePreservesMergeLogic verifies
|
||||
// that chunks from different KnowledgeIDs do NOT accidentally merge across
|
||||
// knowledge boundaries even when they share StartAt/EndAt ranges.
|
||||
func TestGroupAndMergeOverlapping_CrossKnowledgePreservesMergeLogic(t *testing.T) {
|
||||
plugin := &PluginMerge{}
|
||||
chunks := []*types.SearchResult{
|
||||
{ID: "a-1", KnowledgeID: "kb-a", ChunkType: "text", StartAt: 0, EndAt: 100, Score: 0.9},
|
||||
{ID: "b-1", KnowledgeID: "kb-b", ChunkType: "text", StartAt: 0, EndAt: 100, Score: 0.5},
|
||||
}
|
||||
results := plugin.groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
|
||||
require.Len(t, results, 2, "chunks from different KBs must not merge")
|
||||
assert.Equal(t, "a-1", results[0].ID, "higher score from kb-a first")
|
||||
assert.Equal(t, "b-1", results[1].ID, "lower score from kb-b second")
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// filterHistoryResults retrieves history references and filters them by
|
||||
// textual similarity to the current query. Only references that are above
|
||||
// a Jaccard similarity threshold are kept, and their scores are discounted
|
||||
// to reflect that they were not directly retrieved for the current query.
|
||||
// Results already present in currentResults (by chunk ID) are excluded.
|
||||
func filterHistoryResults(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
currentResults []*types.SearchResult,
|
||||
) []*types.SearchResult {
|
||||
const (
|
||||
// minSimilarity is the minimum Jaccard similarity between the current
|
||||
// query and a history chunk's content for it to be injected.
|
||||
minSimilarity = 0.15
|
||||
// historyScoreDiscount reduces the original score of history results
|
||||
// to rank them below freshly-retrieved results of similar relevance.
|
||||
historyScoreDiscount = 0.6
|
||||
// maxHistoryResults caps the number of history results injected to
|
||||
// avoid overwhelming the context with stale references.
|
||||
maxHistoryResults = 3
|
||||
)
|
||||
|
||||
raw := getSearchResultFromHistory(chatManage)
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build a set of chunk IDs already in current results for fast dedup
|
||||
existingIDs := make(map[string]struct{}, len(currentResults))
|
||||
for _, r := range currentResults {
|
||||
existingIDs[r.ID] = struct{}{}
|
||||
}
|
||||
|
||||
// Use RewriteQuery if available (it's the cleaned-up retrieval query),
|
||||
// otherwise fall back to the original query.
|
||||
query := chatManage.RewriteQuery
|
||||
if query == "" {
|
||||
query = chatManage.Query
|
||||
}
|
||||
queryTokens := searchutil.TokenizeSimple(query)
|
||||
|
||||
var filtered []*types.SearchResult
|
||||
for _, r := range raw {
|
||||
if _, exists := existingIDs[r.ID]; exists {
|
||||
continue
|
||||
}
|
||||
contentTokens := searchutil.TokenizeSimple(r.Content)
|
||||
sim := searchutil.Jaccard(queryTokens, contentTokens)
|
||||
if sim < minSimilarity {
|
||||
pipelineInfo(ctx, "Merge", "history_filter_drop", map[string]interface{}{
|
||||
"chunk_id": r.ID,
|
||||
"similarity": sim,
|
||||
})
|
||||
continue
|
||||
}
|
||||
r.MatchType = types.MatchTypeHistory
|
||||
r.Score = r.Score * historyScoreDiscount
|
||||
r.Metadata = ensureMetadata(r.Metadata)
|
||||
r.Metadata["history_similarity"] = strings.TrimRight(strings.TrimRight(
|
||||
fmt.Sprintf("%.4f", sim), "0"), ".")
|
||||
filtered = append(filtered, r)
|
||||
|
||||
pipelineInfo(ctx, "Merge", "history_filter_keep", map[string]interface{}{
|
||||
"chunk_id": r.ID,
|
||||
"similarity": sim,
|
||||
"new_score": r.Score,
|
||||
})
|
||||
|
||||
if len(filtered) >= maxHistoryResults {
|
||||
break
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// mergeSequentialChunks joins sequential current chunk bodies.
|
||||
// Trusted pairs use position-aware merging;
|
||||
// any pair involving edited, expanded, or stale content falls back to
|
||||
// text matching so current content is never discarded on parser coordinates.
|
||||
// Chunks MUST be pre-sorted by ChunkIndex.
|
||||
func (p *PluginMerge) mergeSequentialChunks(
|
||||
ctx context.Context,
|
||||
knowledgeID string,
|
||||
chunks []*types.SearchResult,
|
||||
) []*types.SearchResult {
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mergedGroup struct {
|
||||
result *types.SearchResult
|
||||
lastIndex int
|
||||
}
|
||||
groups := []mergedGroup{{result: chunks[0], lastIndex: chunks[0].ChunkIndex}}
|
||||
for i := 1; i < len(chunks); i++ {
|
||||
current := chunks[i]
|
||||
last := &groups[len(groups)-1]
|
||||
lastChunk := last.result
|
||||
|
||||
switch classifyMerge(lastChunk, last.lastIndex, current) {
|
||||
case mergeSeparate:
|
||||
groups = append(groups, mergedGroup{result: current, lastIndex: current.ChunkIndex})
|
||||
continue
|
||||
case mergeExtend:
|
||||
lastChunk.Content = appendTrustedContent(lastChunk.Content, current.Content, lastChunk.EndAt-current.StartAt)
|
||||
lastChunk.EndAt = current.EndAt
|
||||
recordMergedChild(ctx, knowledgeID, lastChunk, current, "image_merge")
|
||||
case mergeSubsume:
|
||||
recordMergedChild(ctx, knowledgeID, lastChunk, current, "image_merge_contained")
|
||||
case mergeJoinDistinct:
|
||||
lastChunk.Content = searchutil.JoinChunkContent(lastChunk.Content, current.Content, "\n\n")
|
||||
recordMergedChild(ctx, knowledgeID, lastChunk, current, "image_merge_contained")
|
||||
case mergeJoinText:
|
||||
lastChunk.Content = searchutil.JoinChunkContent(lastChunk.Content, current.Content, "\n\n")
|
||||
recordMergedChild(ctx, knowledgeID, lastChunk, current, "image_merge")
|
||||
}
|
||||
|
||||
// Extend the merged group's span and keep the higher score.
|
||||
if current.ChunkIndex > last.lastIndex {
|
||||
last.lastIndex = current.ChunkIndex
|
||||
}
|
||||
if current.Score > lastChunk.Score {
|
||||
lastChunk.Score = current.Score
|
||||
}
|
||||
}
|
||||
|
||||
merged := make([]*types.SearchResult, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
merged = append(merged, group.result)
|
||||
}
|
||||
|
||||
// Sort merged chunks by score (highest first)
|
||||
sort.Slice(merged, func(i, j int) bool {
|
||||
return merged[i].Score > merged[j].Score
|
||||
})
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// appendTrustedContent joins a trusted pair using the overlap the coordinates
|
||||
// already give us, and only falls back to text search when the overlap does not
|
||||
// match character for character (HTML entities or synthetic table headers can
|
||||
// keep the length invariant while changing the body).
|
||||
//
|
||||
// The fallback matters both ways: searching for the longest suffix match would
|
||||
// otherwise mistake a repeated table row or log line for the overlap and drop
|
||||
// real content, which is exactly what the position path is meant to avoid.
|
||||
func appendTrustedContent(acc, next string, positionOverlap int) string {
|
||||
if merged, ok := searchutil.AppendWithExactOverlap(acc, next, positionOverlap); ok {
|
||||
return merged
|
||||
}
|
||||
return searchutil.AppendWithOverlap(acc, next, positionOverlap)
|
||||
}
|
||||
|
||||
// chunkTrusted reports whether a result's StartAt/EndAt can be trusted for
|
||||
// position-based merging: unedited, not pipeline-rewritten, valid range, and
|
||||
// splitter-consistent length (runeLen(Content) == EndAt-StartAt).
|
||||
func chunkTrusted(chunk *types.SearchResult) bool {
|
||||
return chunk.ContentRevision == 0 &&
|
||||
!chunk.ContentRewritten &&
|
||||
chunk.EndAt > chunk.StartAt &&
|
||||
runeLen(chunk.Content) == chunk.EndAt-chunk.StartAt
|
||||
}
|
||||
|
||||
// mergeSituation labels how the current result relates to the group's leading
|
||||
// result in document order. The classifier decides it once; the merge loop
|
||||
// switches on it without nesting.
|
||||
type mergeSituation int
|
||||
|
||||
const (
|
||||
// mergeSeparate: current is not mergeable, it starts a new group
|
||||
// (position gap, or untrusted pair that is neither text-contained nor
|
||||
// index-sequential).
|
||||
mergeSeparate mergeSituation = iota
|
||||
// mergeExtend: trusted pair with partial overlap or adjacency; the
|
||||
// non-overlapping suffix is appended with text-verified trimming.
|
||||
mergeExtend
|
||||
// mergeSubsume: trusted pair, range-contained and text-verified; current
|
||||
// is recorded without touching the merged content.
|
||||
mergeSubsume
|
||||
// mergeJoinDistinct: trusted pair, range-contained but textually distinct;
|
||||
// current is kept via a text join, breaking the length invariant so later
|
||||
// pairs degrade to the text path.
|
||||
mergeJoinDistinct
|
||||
// mergeJoinText: untrusted pair that is text-contained or index-sequential;
|
||||
// joined via pure text matching.
|
||||
mergeJoinText
|
||||
)
|
||||
|
||||
// classifyMerge labels the pair relationship so the merge loop can dispatch
|
||||
// with a flat switch. It needs the group's running lastIndex for the untrusted
|
||||
// sequentiality check.
|
||||
func classifyMerge(lastChunk *types.SearchResult, lastIndex int, current *types.SearchResult) mergeSituation {
|
||||
if chunkTrusted(lastChunk) && chunkTrusted(current) && current.StartAt >= lastChunk.StartAt {
|
||||
switch {
|
||||
case current.StartAt > lastChunk.EndAt:
|
||||
return mergeSeparate
|
||||
case current.EndAt > lastChunk.EndAt:
|
||||
return mergeExtend
|
||||
case searchutil.ContainsChunkContent(lastChunk.Content, current.Content):
|
||||
return mergeSubsume
|
||||
default:
|
||||
return mergeJoinDistinct
|
||||
}
|
||||
}
|
||||
|
||||
textContained := searchutil.ContainsChunkContent(lastChunk.Content, current.Content) ||
|
||||
searchutil.ContainsChunkContent(current.Content, lastChunk.Content)
|
||||
sequential := current.ChunkIndex == lastIndex+1
|
||||
if !textContained && !sequential {
|
||||
return mergeSeparate
|
||||
}
|
||||
return mergeJoinText
|
||||
}
|
||||
|
||||
// recordMergedChild records a merged chunk on the group result: appends its ID
|
||||
// to SubChunkID (deduplicated) and merges its ImageInfo, warning on failure.
|
||||
// warnKey distinguishes merge contexts in pipeline diagnostics.
|
||||
func recordMergedChild(ctx context.Context, knowledgeID string, target, source *types.SearchResult, warnKey string) {
|
||||
if !containsID(target.SubChunkID, source.ID) {
|
||||
target.SubChunkID = append(target.SubChunkID, source.ID)
|
||||
}
|
||||
if err := mergeImageInfo(ctx, target, source); err != nil {
|
||||
pipelineWarn(ctx, "Merge", warnKey, map[string]any{
|
||||
"knowledge_id": knowledgeID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mergeImageInfo merges ImageInfo from source into target, deduplicating by URL.
|
||||
func mergeImageInfo(ctx context.Context, target *types.SearchResult, source *types.SearchResult) error {
|
||||
if source.ImageInfo == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sourceImageInfos []types.ImageInfo
|
||||
if err := json.Unmarshal([]byte(source.ImageInfo), &sourceImageInfos); err != nil {
|
||||
pipelineWarn(ctx, "Merge", "image_unmarshal_source", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
if len(sourceImageInfos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var targetImageInfos []types.ImageInfo
|
||||
if target.ImageInfo != "" {
|
||||
if err := json.Unmarshal([]byte(target.ImageInfo), &targetImageInfos); err != nil {
|
||||
pipelineWarn(ctx, "Merge", "image_unmarshal_target", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
target.ImageInfo = source.ImageInfo
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
targetImageInfos = append(targetImageInfos, sourceImageInfos...)
|
||||
|
||||
uniqueMap := make(map[string]bool)
|
||||
uniqueImageInfos := make([]types.ImageInfo, 0, len(targetImageInfos))
|
||||
|
||||
for _, imgInfo := range targetImageInfos {
|
||||
if imgInfo.URL != "" && !uniqueMap[imgInfo.URL] {
|
||||
uniqueMap[imgInfo.URL] = true
|
||||
uniqueImageInfos = append(uniqueImageInfos, imgInfo)
|
||||
}
|
||||
}
|
||||
|
||||
mergedImageInfoJSON, err := json.Marshal(uniqueImageInfos)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Merge", "image_marshal", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
target.ImageInfo = string(mergedImageInfoJSON)
|
||||
pipelineInfo(ctx, "Merge", "image_merged", map[string]interface{}{
|
||||
"image_refs": len(uniqueImageInfos),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestResolveParentChunksUsesCurrentContentAndImageURLs(t *testing.T) {
|
||||
parent := &types.Chunk{
|
||||
ID: "parent", ChunkType: types.ChunkTypeParentText, ChunkIndex: 7,
|
||||
Content: "manually inserted prefix\n\n\n\nparent body\n\n",
|
||||
}
|
||||
imageInfo, err := json.Marshal([]types.ImageInfo{{URL: "u2", OCRText: "two"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &expandChunkRepo{
|
||||
chunks: map[string]*types.Chunk{"parent": parent},
|
||||
children: map[string][]*types.Chunk{
|
||||
"child": {
|
||||
{
|
||||
ID: "image", ParentChunkID: "child", ChunkType: types.ChunkTypeImageOCR,
|
||||
ImageInfo: string(imageInfo),
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
plugin := &PluginMerge{chunkRepo: repo}
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
|
||||
result := &types.SearchResult{
|
||||
ID: "child", KnowledgeID: "doc", ChunkType: string(types.ChunkTypeText),
|
||||
ParentChunkID: "parent", Content: "current edited child body", StartAt: 999, EndAt: 1001,
|
||||
}
|
||||
|
||||
got := plugin.resolveParentChunks(ctx, &types.ChatManage{}, []*types.SearchResult{result})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("result count = %d, want 1", len(got))
|
||||
}
|
||||
if !strings.Contains(got[0].Content, "current edited child body") {
|
||||
t.Fatalf("edited child content was lost: %q", got[0].Content)
|
||||
}
|
||||
if strings.Contains(got[0].Content, "u1") || !strings.Contains(got[0].Content, "u2") {
|
||||
t.Fatalf("parent images were not scoped by URL: %q", got[0].Content)
|
||||
}
|
||||
if got[0].StartAt != 999 || got[0].EndAt != 1001 {
|
||||
t.Fatalf("source coordinates changed to [%d,%d)", got[0].StartAt, got[0].EndAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveImageChunkKeepsGrandparentContextWithoutCoordinateSlicing(t *testing.T) {
|
||||
imageInfo, err := json.Marshal([]types.ImageInfo{{URL: "u1", OCRText: "matched image"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &expandChunkRepo{
|
||||
chunks: map[string]*types.Chunk{
|
||||
"text": {
|
||||
ID: "text", ParentChunkID: "parent", ChunkType: types.ChunkTypeText, ChunkIndex: 4,
|
||||
Content: "current edited text child\n\n", StartAt: 900, EndAt: 910,
|
||||
},
|
||||
"parent": {
|
||||
ID: "parent", ChunkType: types.ChunkTypeParentText,
|
||||
Content: "grandparent context before\n\n\n\ngrandparent context after\n\n",
|
||||
StartAt: 0, EndAt: 100,
|
||||
},
|
||||
},
|
||||
children: map[string][]*types.Chunk{
|
||||
"text": {{
|
||||
ID: "image", ParentChunkID: "text", ChunkType: types.ChunkTypeImageOCR,
|
||||
ImageInfo: string(imageInfo),
|
||||
IsEnabled: true,
|
||||
}},
|
||||
},
|
||||
}
|
||||
plugin := &PluginMerge{chunkRepo: repo}
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
|
||||
result := &types.SearchResult{
|
||||
ID: "image", KnowledgeID: "doc", ChunkType: string(types.ChunkTypeImageOCR),
|
||||
ParentChunkID: "text", Content: "matched image", ImageInfo: string(imageInfo),
|
||||
StartAt: 500, EndAt: 510,
|
||||
}
|
||||
|
||||
got := plugin.resolveParentChunks(ctx, &types.ChatManage{}, []*types.SearchResult{result})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("result count = %d, want 1", len(got))
|
||||
}
|
||||
for _, want := range []string{"grandparent context before", "grandparent context after", "current edited text child", "u1"} {
|
||||
if !strings.Contains(got[0].Content, want) {
|
||||
t.Fatalf("image result lost %q: %q", want, got[0].Content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got[0].Content, "u2") {
|
||||
t.Fatalf("sibling image leaked into image result: %q", got[0].Content)
|
||||
}
|
||||
if got[0].StartAt != 500 || got[0].EndAt != 510 {
|
||||
t.Fatalf("source coordinates changed to [%d,%d)", got[0].StartAt, got[0].EndAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopedTextChildIDs(t *testing.T) {
|
||||
parentMap := map[string]*types.Chunk{
|
||||
"parent-1": {ID: "parent-1", ChunkType: types.ChunkTypeParentText},
|
||||
"text-x": {ID: "text-x", ChunkType: types.ChunkTypeText},
|
||||
}
|
||||
results := []*types.SearchResult{
|
||||
{ID: "text-1", ChunkType: string(types.ChunkTypeText), ParentChunkID: "parent-1"},
|
||||
{ID: "img-1", ChunkType: string(types.ChunkTypeImageOCR), ParentChunkID: "text-2"},
|
||||
{ID: "text-3", ChunkType: string(types.ChunkTypeText), ParentChunkID: "text-x"}, // not parent_text
|
||||
}
|
||||
ids := collectScopedTextChildIDs(results, parentMap)
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("ids: %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignScopedImageInfo_FiltersToContentURLs(t *testing.T) {
|
||||
all, _ := json.Marshal([]types.ImageInfo{
|
||||
{URL: "u1", OCRText: "one"},
|
||||
{URL: "u2", OCRText: "two"},
|
||||
})
|
||||
r := &types.SearchResult{
|
||||
Content: "",
|
||||
ImageInfo: string(all),
|
||||
}
|
||||
assignScopedImageInfo(r, nil, "missing-child")
|
||||
var infos []types.ImageInfo
|
||||
if err := json.Unmarshal([]byte(r.ImageInfo), &infos); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(infos) != 1 || infos[0].URL != "u2" {
|
||||
t.Fatalf("filtered: %+v", infos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentChildImageHit_WindowSliceAndFilter(t *testing.T) {
|
||||
parentContent := "\n\n\n\n"
|
||||
textStart := len([]rune("\n\n"))
|
||||
textEnd := textStart + len([]rune(""))
|
||||
sliced := searchutil.SliceContentByDocumentRange(parentContent, 0, textStart, textEnd)
|
||||
if sliced != "" {
|
||||
t.Fatalf("slice: got %q", sliced)
|
||||
}
|
||||
|
||||
all, _ := json.Marshal([]types.ImageInfo{
|
||||
{URL: "u1"}, {URL: "u2"}, {URL: "u3"},
|
||||
})
|
||||
filtered := searchutil.FilterImageInfoByContentURLs(sliced, string(all))
|
||||
var infos []types.ImageInfo
|
||||
if err := json.Unmarshal([]byte(filtered), &infos); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(infos) != 1 || infos[0].URL != "u2" {
|
||||
t.Fatalf("infos: %+v", infos)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// rangeText returns deterministic text of exactly n runes so fixtures satisfy
|
||||
// the splitter invariant runeLen(Content) == EndAt-StartAt.
|
||||
func rangeText(seed rune, n int) string {
|
||||
return strings.Repeat(string(seed), n)
|
||||
}
|
||||
|
||||
// trustedResult builds a SearchResult whose coordinates are trustworthy
|
||||
// (unedited, valid range, splitter-consistent length).
|
||||
func trustedResult(id string, index, start, end int, content string) *types.SearchResult {
|
||||
return &types.SearchResult{
|
||||
ID: id, ChunkIndex: index, StartAt: start, EndAt: end, Content: content, Score: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
// mergeGrouped runs the merge stage as the pipeline does: group by knowledge
|
||||
// and chunk type, then merge sequential bodies. Observable behavior only.
|
||||
func mergeGrouped(chunks []*types.SearchResult) []*types.SearchResult {
|
||||
return (&PluginMerge{}).groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
}
|
||||
|
||||
// All fixtures below share KnowledgeID "doc", ChunkType "text" and Score 1.0,
|
||||
// so results form a single group and the deterministic post-merge sort keeps
|
||||
// their relative order by ChunkIndex.
|
||||
func docChunks(chunks ...*types.SearchResult) []*types.SearchResult {
|
||||
for _, c := range chunks {
|
||||
c.KnowledgeID = "doc"
|
||||
c.ChunkType = "text"
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedOverlapTrimsAndExtendsRange(t *testing.T) {
|
||||
// 20-rune real overlap between two trusted chunks: the merged body must
|
||||
// contain the overlap exactly once and extend the result range.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 80)+rangeText('b', 20))
|
||||
second := trustedResult("c2", 2, 80, 200, rangeText('b', 20)+rangeText('c', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, rangeText('a', 80)+rangeText('b', 20)+rangeText('c', 100), results[0].Content)
|
||||
assert.Equal(t, 200, results[0].EndAt, "merged range must extend to the second chunk")
|
||||
assert.ElementsMatch(t, []string{"c2"}, results[0].SubChunkID)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedOverlapBeyondTextSearchWindow(t *testing.T) {
|
||||
// A real overlap wider than the text matcher's 400-rune window: the position
|
||||
// path knows the exact amount, so the overlap must not survive twice.
|
||||
overlap := rangeText('o', 500)
|
||||
first := trustedResult("c1", 1, 0, 1000, rangeText('a', 500)+overlap)
|
||||
second := trustedResult("c2", 2, 500, 1500, overlap+rangeText('b', 500))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, rangeText('a', 500)+overlap+rangeText('b', 500), results[0].Content)
|
||||
assert.Equal(t, 1500, results[0].EndAt)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedAdjacentRepeatedTextKeepsEveryRow(t *testing.T) {
|
||||
// Adjacent trusted chunks (positional overlap 0) made of one repeating row:
|
||||
// searching for the longest suffix match would mistake the repetition for an
|
||||
// overlap and drop rows, so the exact-overlap path must concatenate verbatim.
|
||||
row := "| cell | cell |\n"
|
||||
firstBody := rangeText('x', 100) + strings.Repeat(row, 2)
|
||||
secondBody := strings.Repeat(row, 3) + rangeText('y', 60)
|
||||
first := trustedResult("c1", 1, 0, runeLen(firstBody), firstBody)
|
||||
second := trustedResult("c2", 2, runeLen(firstBody), runeLen(firstBody)+runeLen(secondBody), secondBody)
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, firstBody+secondBody, results[0].Content)
|
||||
assert.Equal(t, 5, strings.Count(results[0].Content, row), "no repeated row may be dropped")
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedOverlapTextMismatchFallsBackToTextMatch(t *testing.T) {
|
||||
// Length invariant holds but the bodies disagree inside the overlap window
|
||||
// (HTML entities, synthetic headers): the exact path must refuse and the text
|
||||
// fallback must keep both bodies rather than trim on coordinates.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 80)+rangeText('b', 20))
|
||||
second := trustedResult("c2", 2, 80, 200, rangeText('c', 20)+rangeText('d', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 20))
|
||||
assert.Contains(t, results[0].Content, rangeText('c', 20))
|
||||
assert.Contains(t, results[0].Content, rangeText('d', 100))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedAdjacentJoinsSeamlessly(t *testing.T) {
|
||||
// Adjacent trusted chunks are contiguous in the original document: the
|
||||
// merged body must be the exact concatenation, with no separator inserted.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
second := trustedResult("c2", 2, 100, 200, rangeText('b', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, rangeText('a', 100)+rangeText('b', 100), results[0].Content)
|
||||
assert.Equal(t, 200, results[0].EndAt)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedGapStartsNewGroup(t *testing.T) {
|
||||
// A real position gap means content in between is missing: chunks must
|
||||
// stay separate instead of being joined with a fabricated separator.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
second := trustedResult("c2", 2, 150, 250, rangeText('b', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 2, "position gap must not merge")
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_RewrittenResultDoesNotTrustCoordinates(t *testing.T) {
|
||||
// A result whose content was replaced by the pipeline (parent or neighbor
|
||||
// expansion) must never enter the position path, even when the replacement
|
||||
// happens to satisfy the length invariant and share boundary text: its
|
||||
// stale coordinates must not extend the merged group's range.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
rewritten := trustedResult("c2", 2, 80, 200, rangeText('a', 20)+rangeText('b', 100))
|
||||
rewritten.ContentRewritten = true
|
||||
|
||||
results := mergeGrouped(docChunks(first, rewritten))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 100))
|
||||
assert.Equal(t, 100, results[0].EndAt, "rewritten result must not extend the range via stale coordinates")
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedContainedSubsumes(t *testing.T) {
|
||||
// Range-contained and text-verified: one result whose content is the
|
||||
// outer body; the inner chunk is recorded, not duplicated.
|
||||
outer := trustedResult("outer", 1, 0, 200, rangeText('a', 200))
|
||||
inner := trustedResult("inner", 2, 50, 100, rangeText('a', 50))
|
||||
|
||||
results := mergeGrouped(docChunks(outer, inner))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, rangeText('a', 200), results[0].Content)
|
||||
assert.ElementsMatch(t, []string{"inner"}, results[0].SubChunkID)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedContainedDistinctKeepsBoth(t *testing.T) {
|
||||
// Range-contained but textually distinct: both bodies must survive; the
|
||||
// inner one must never be dropped on coordinates alone.
|
||||
outer := trustedResult("outer", 1, 0, 100, rangeText('a', 100))
|
||||
inner := trustedResult("inner", 2, 50, 60, rangeText('b', 10))
|
||||
|
||||
results := mergeGrouped(docChunks(outer, inner))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 10))
|
||||
assert.ElementsMatch(t, []string{"inner"}, results[0].SubChunkID)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_EditedPairKeepsBoth(t *testing.T) {
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
edited := &types.SearchResult{
|
||||
ID: "c2", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 2, StartAt: 80, EndAt: 200,
|
||||
Content: "rewritten body", ContentRevision: 1, Score: 1.0,
|
||||
}
|
||||
|
||||
results := mergeGrouped(docChunks(first, edited))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, "rewritten body")
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_SameLengthRewriteKeepsBoth(t *testing.T) {
|
||||
// An edit that happens to keep the splitter length invariant must still
|
||||
// never cause the rewritten body to be swallowed on coordinates.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
edited := &types.SearchResult{
|
||||
ID: "c2", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 2, StartAt: 80, EndAt: 200,
|
||||
Content: rangeText('b', 120), ContentRevision: 1, Score: 1.0,
|
||||
}
|
||||
|
||||
results := mergeGrouped(docChunks(first, edited))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 120))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_InvariantBreakKeepsBoth(t *testing.T) {
|
||||
// ContentRevision 0 but length inconsistent with the range (e.g. zero-width
|
||||
// table headers, HTML entities): both bodies must survive.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
stale := &types.SearchResult{
|
||||
ID: "c2", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 2, StartAt: 80, EndAt: 200,
|
||||
Content: "short", Score: 1.0,
|
||||
}
|
||||
|
||||
results := mergeGrouped(docChunks(first, stale))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, "short")
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_StartInversionKeepsBoth(t *testing.T) {
|
||||
// A chunk whose StartAt precedes the group's but has a higher ChunkIndex
|
||||
// contradicts document order: both bodies must survive.
|
||||
first := trustedResult("c1", 1, 100, 200, rangeText('a', 100))
|
||||
inverted := trustedResult("c2", 2, 50, 150, rangeText('b', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, inverted))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 100))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_EditedNonSequentialSeparates(t *testing.T) {
|
||||
// Edited chunks that are neither text-contained nor index-sequential stay
|
||||
// separate results.
|
||||
first := &types.SearchResult{
|
||||
ID: "c1", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 1, StartAt: 0, EndAt: 50,
|
||||
Content: rangeText('a', 50), ContentRevision: 1, Score: 1.0,
|
||||
}
|
||||
third := &types.SearchResult{
|
||||
ID: "c3", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 3, StartAt: 200, EndAt: 250,
|
||||
Content: rangeText('b', 50), ContentRevision: 1, Score: 1.0,
|
||||
}
|
||||
|
||||
results := mergeGrouped(docChunks(first, third))
|
||||
|
||||
require.Len(t, results, 2, "non-sequential edited chunks must not merge")
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_MidGroupDriftKeepsAllBodies(t *testing.T) {
|
||||
// When an overlap cannot be trimmed the merged body no longer matches its
|
||||
// range; subsequent pairs must still merge safely without losing content.
|
||||
c1 := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
c2 := trustedResult("c2", 2, 90, 200, rangeText('c', 110))
|
||||
c3 := trustedResult("c3", 3, 200, 300, rangeText('e', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(c1, c2, c3))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('c', 110))
|
||||
assert.Contains(t, results[0].Content, rangeText('e', 100))
|
||||
assert.ElementsMatch(t, []string{"c2", "c3"}, results[0].SubChunkID)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// --- IntoChatMessage tests ---
|
||||
|
||||
func TestIntoChatMessage_NoKBRetrieval(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
Query: "hello world",
|
||||
},
|
||||
PipelineState: types.PipelineState{
|
||||
Intent: types.IntentChitchat,
|
||||
},
|
||||
}
|
||||
plugin := &PluginIntoChatMessage{messageService: nil}
|
||||
nextCalled := false
|
||||
err := plugin.OnEvent(context.Background(), types.INTO_CHAT_MESSAGE, cm, func() *PluginError {
|
||||
nextCalled = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !nextCalled {
|
||||
t.Fatal("next() was not called")
|
||||
}
|
||||
if cm.UserContent != "hello world" {
|
||||
t.Errorf("UserContent: got %q, want %q", cm.UserContent, "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntoChatMessage_WithMergeResults(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
Query: "test query",
|
||||
SummaryConfig: types.SummaryConfig{
|
||||
ContextTemplate: "Question: {{query}}\n\nReferences:\n{{contexts}}",
|
||||
},
|
||||
},
|
||||
PipelineState: types.PipelineState{
|
||||
MergeResult: []*types.SearchResult{
|
||||
{Content: "chunk A content"},
|
||||
{Content: "chunk B content"},
|
||||
},
|
||||
},
|
||||
}
|
||||
plugin := &PluginIntoChatMessage{messageService: nil}
|
||||
nextCalled := false
|
||||
err := plugin.OnEvent(context.Background(), types.INTO_CHAT_MESSAGE, cm, func() *PluginError {
|
||||
nextCalled = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !nextCalled {
|
||||
t.Fatal("next() was not called")
|
||||
}
|
||||
if cm.UserContent == "" {
|
||||
t.Fatal("expected UserContent to be populated")
|
||||
}
|
||||
if !contains(cm.UserContent, "test query") {
|
||||
t.Errorf("UserContent should contain query, got: %s", cm.UserContent)
|
||||
}
|
||||
if !contains(cm.UserContent, "chunk A content") {
|
||||
t.Errorf("UserContent should contain chunk A, got: %s", cm.UserContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntoChatMessage_ImageDescriptionAppended(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
Query: "what is this?",
|
||||
ChatModelSupportsVision: false,
|
||||
},
|
||||
PipelineState: types.PipelineState{
|
||||
Intent: types.IntentChitchat,
|
||||
ImageDescription: "a cat sitting on a mat",
|
||||
},
|
||||
}
|
||||
plugin := &PluginIntoChatMessage{messageService: nil}
|
||||
_ = plugin.OnEvent(context.Background(), types.INTO_CHAT_MESSAGE, cm, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
if !contains(cm.UserContent, "a cat sitting on a mat") {
|
||||
t.Errorf("UserContent should contain image description, got: %s", cm.UserContent)
|
||||
}
|
||||
}
|
||||
|
||||
// --- PipelineBuilder tests ---
|
||||
|
||||
func TestPipelineBuilder_Basic(t *testing.T) {
|
||||
pipeline := types.NewPipelineBuilder().
|
||||
Add(types.LOAD_HISTORY).
|
||||
Add(types.CHAT_COMPLETION_STREAM).
|
||||
Build()
|
||||
|
||||
if len(pipeline) != 2 {
|
||||
t.Fatalf("expected 2 stages, got %d", len(pipeline))
|
||||
}
|
||||
if pipeline[0] != types.LOAD_HISTORY {
|
||||
t.Errorf("stage 0: got %v, want %v", pipeline[0], types.LOAD_HISTORY)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineBuilder_AddIf(t *testing.T) {
|
||||
pipeline := types.NewPipelineBuilder().
|
||||
Add(types.LOAD_HISTORY).
|
||||
AddIf(false, types.QUERY_UNDERSTAND).
|
||||
AddIf(true, types.CHAT_COMPLETION_STREAM).
|
||||
Build()
|
||||
|
||||
if len(pipeline) != 2 {
|
||||
t.Fatalf("expected 2 stages (QUERY_UNDERSTAND skipped), got %d", len(pipeline))
|
||||
}
|
||||
if pipeline[1] != types.CHAT_COMPLETION_STREAM {
|
||||
t.Errorf("stage 1: got %v, want %v", pipeline[1], types.CHAT_COMPLETION_STREAM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineBuilder_Empty(t *testing.T) {
|
||||
pipeline := types.NewPipelineBuilder().Build()
|
||||
if len(pipeline) != 0 {
|
||||
t.Fatalf("expected 0 stages, got %d", len(pipeline))
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsString(s, substr))
|
||||
}
|
||||
|
||||
func containsString(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
retrievalProgressTool = "knowledge_search"
|
||||
queryUnderstandProgressTool = "query_understand"
|
||||
|
||||
retrievalSourceKnowledge = "knowledge"
|
||||
retrievalSourceWeb = "web"
|
||||
retrievalSourceMixed = "mixed"
|
||||
)
|
||||
|
||||
// StageProgress tracks an in-flight pipeline progress tool_call.
|
||||
type StageProgress struct {
|
||||
toolCallID string
|
||||
toolName string
|
||||
}
|
||||
|
||||
// ShouldEmitQueryUnderstandProgress reports whether the query-understand stage
|
||||
// will actually run (rewrite enabled or images attached).
|
||||
func ShouldEmitQueryUnderstandProgress(chatManage *types.ChatManage) bool {
|
||||
if chatManage == nil {
|
||||
return false
|
||||
}
|
||||
return chatManage.EnableRewrite || len(chatManage.Images) > 0
|
||||
}
|
||||
|
||||
// IsConsolidatedRetrievalStage reports whether a pipeline stage belongs to the
|
||||
// single user-visible "knowledge search" progress window (search → rerank → merge).
|
||||
func IsConsolidatedRetrievalStage(stage types.EventType, chatManage *types.ChatManage) bool {
|
||||
if chatManage == nil {
|
||||
return false
|
||||
}
|
||||
switch stage {
|
||||
case types.CHUNK_SEARCH_PARALLEL, types.CHUNK_RERANK, types.CHUNK_MERGE, types.FILTER_TOP_K:
|
||||
return chatManage.NeedsRetrieval()
|
||||
case types.WEB_FETCH:
|
||||
return chatManage.WebSearchEnabled
|
||||
case types.DATA_ANALYSIS:
|
||||
return chatManage.DataAnalysisEnabled && chatManage.NeedsRetrieval()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// LastConsolidatedRetrievalStage returns the last retrieval-related stage in the
|
||||
// assembled pipeline, or empty when none apply.
|
||||
func LastConsolidatedRetrievalStage(eventList []types.EventType, chatManage *types.ChatManage) types.EventType {
|
||||
var last types.EventType
|
||||
for _, stage := range eventList {
|
||||
if IsConsolidatedRetrievalStage(stage, chatManage) {
|
||||
last = stage
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// ShouldCloseRetrievalProgress reports whether the consolidated retrieval
|
||||
// progress window must be closed after a pipeline stage. It returns true when
|
||||
// either the planned last retrieval stage completed, or a retrieval stage
|
||||
// short-circuited the pipeline with an error — including ErrSearchNothing,
|
||||
// which routes into the fallback response. Closing on the error paths prevents
|
||||
// the frontend "knowledge_search" spinner from hanging forever when the
|
||||
// pipeline early-returns before reaching the last retrieval stage.
|
||||
func ShouldCloseRetrievalProgress(stage, lastRetrievalStage types.EventType, stageErr *PluginError) bool {
|
||||
return stage == lastRetrievalStage || stageErr != nil
|
||||
}
|
||||
|
||||
// BeginRetrievalProgress emits a single pending knowledge_search tool_call.
|
||||
func BeginRetrievalProgress(ctx context.Context, chatManage *types.ChatManage) *StageProgress {
|
||||
if chatManage == nil || chatManage.EventBus == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
toolCallID := uuid.New().String()
|
||||
args := map[string]any{
|
||||
"search_source": retrievalSearchSource(chatManage),
|
||||
}
|
||||
if chatManage.RewriteQuery != "" {
|
||||
args["query"] = chatManage.RewriteQuery
|
||||
} else if chatManage.Query != "" {
|
||||
args["query"] = chatManage.Query
|
||||
}
|
||||
|
||||
_ = chatManage.EventBus.Emit(ctx, types.Event{
|
||||
Type: types.EventType(event.EventAgentToolCall),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentToolCallData{
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: retrievalProgressTool,
|
||||
Arguments: args,
|
||||
},
|
||||
})
|
||||
|
||||
return &StageProgress{toolCallID: toolCallID, toolName: retrievalProgressTool}
|
||||
}
|
||||
|
||||
// BeginQueryUnderstandProgress emits a pending query_understand tool_call.
|
||||
func BeginQueryUnderstandProgress(ctx context.Context, chatManage *types.ChatManage) *StageProgress {
|
||||
if chatManage == nil || chatManage.EventBus == nil || !ShouldEmitQueryUnderstandProgress(chatManage) {
|
||||
return nil
|
||||
}
|
||||
|
||||
toolCallID := uuid.New().String()
|
||||
args := map[string]any{}
|
||||
if chatManage.Query != "" {
|
||||
args["query"] = chatManage.Query
|
||||
}
|
||||
if len(chatManage.Images) > 0 {
|
||||
args["has_images"] = true
|
||||
}
|
||||
|
||||
_ = chatManage.EventBus.Emit(ctx, types.Event{
|
||||
Type: types.EventType(event.EventAgentToolCall),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentToolCallData{
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: queryUnderstandProgressTool,
|
||||
Arguments: args,
|
||||
},
|
||||
})
|
||||
|
||||
return &StageProgress{toolCallID: toolCallID, toolName: queryUnderstandProgressTool}
|
||||
}
|
||||
|
||||
// EndQueryUnderstandProgress emits the matching tool_result for query understanding.
|
||||
func EndQueryUnderstandProgress(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
progress *StageProgress,
|
||||
start time.Time,
|
||||
stageErr *PluginError,
|
||||
) {
|
||||
if progress == nil || chatManage == nil || chatManage.EventBus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
success := stageErr == nil
|
||||
output := ""
|
||||
if success {
|
||||
output = "已完成问题理解"
|
||||
}
|
||||
|
||||
var errMsg string
|
||||
if !success && stageErr != nil && stageErr.Err != nil {
|
||||
errMsg = stageErr.Err.Error()
|
||||
}
|
||||
|
||||
_ = chatManage.EventBus.Emit(ctx, types.Event{
|
||||
Type: types.EventType(event.EventAgentToolResult),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentToolResultData{
|
||||
ToolCallID: progress.toolCallID,
|
||||
ToolName: queryUnderstandProgressTool,
|
||||
Output: output,
|
||||
Error: errMsg,
|
||||
Success: success,
|
||||
Duration: time.Since(start).Milliseconds(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// EndRetrievalProgress emits the matching tool_result for the consolidated retrieval window.
|
||||
func EndRetrievalProgress(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
progress *StageProgress,
|
||||
start time.Time,
|
||||
stageErr *PluginError,
|
||||
) {
|
||||
if progress == nil || chatManage == nil || chatManage.EventBus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
count, docCount, webCount := retrievalResultBreakdown(chatManage)
|
||||
|
||||
// ErrSearchNothing means retrieval short-circuited the pipeline into the
|
||||
// fallback answer: nothing survived filtering, and the fallback prompt gets
|
||||
// a knowledge-base listing rather than any retrieved chunk. So no chunk
|
||||
// reached the model, and none can be cited either. Reporting the raw hits as
|
||||
// the result count is what made the timeline promise results the answer never
|
||||
// saw — and offer a row with no references to open. The raw hits stay
|
||||
// available as candidates, which is the useful part when a threshold is what
|
||||
// rejected them.
|
||||
candidateCount := 0
|
||||
if stageErr == ErrSearchNothing {
|
||||
candidateCount = count
|
||||
count, docCount, webCount = 0, 0, 0
|
||||
}
|
||||
|
||||
searchSource := retrievalSearchSource(chatManage)
|
||||
if count > 0 {
|
||||
switch {
|
||||
case docCount > 0 && webCount > 0:
|
||||
searchSource = retrievalSourceMixed
|
||||
case webCount > 0:
|
||||
searchSource = retrievalSourceWeb
|
||||
default:
|
||||
searchSource = retrievalSourceKnowledge
|
||||
}
|
||||
}
|
||||
success := stageErr == nil || stageErr == ErrSearchNothing
|
||||
output := ""
|
||||
if success {
|
||||
switch {
|
||||
case count > 0:
|
||||
output = fmt.Sprintf("检索到 %d 条相关内容", count)
|
||||
case candidateCount > 0:
|
||||
output = fmt.Sprintf("命中 %d 条候选,相关性不足,未用于回答", candidateCount)
|
||||
default:
|
||||
output = "未检索到相关内容"
|
||||
}
|
||||
}
|
||||
|
||||
var errMsg string
|
||||
if !success && stageErr != nil && stageErr.Err != nil {
|
||||
errMsg = stageErr.Err.Error()
|
||||
}
|
||||
|
||||
_ = chatManage.EventBus.Emit(ctx, types.Event{
|
||||
Type: types.EventType(event.EventAgentToolResult),
|
||||
SessionID: chatManage.SessionID,
|
||||
Data: event.AgentToolResultData{
|
||||
ToolCallID: progress.toolCallID,
|
||||
ToolName: retrievalProgressTool,
|
||||
Output: output,
|
||||
Error: errMsg,
|
||||
Success: success,
|
||||
Duration: time.Since(start).Milliseconds(),
|
||||
Data: map[string]interface{}{
|
||||
"count": count,
|
||||
"doc_count": docCount,
|
||||
"web_count": webCount,
|
||||
"search_source": searchSource,
|
||||
"candidate_count": candidateCount,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func hasKBRetrievalTargets(chatManage *types.ChatManage) bool {
|
||||
if chatManage == nil {
|
||||
return false
|
||||
}
|
||||
return types.HasKnowledgeRetrievalScope(
|
||||
chatManage.SearchTargets,
|
||||
chatManage.KnowledgeBaseIDs,
|
||||
chatManage.KnowledgeIDs,
|
||||
)
|
||||
}
|
||||
|
||||
func retrievalSearchSource(chatManage *types.ChatManage) string {
|
||||
hasKB := hasKBRetrievalTargets(chatManage)
|
||||
hasWeb := chatManage != nil && chatManage.WebSearchEnabled
|
||||
switch {
|
||||
case hasKB && hasWeb:
|
||||
return retrievalSourceMixed
|
||||
case hasWeb:
|
||||
return retrievalSourceWeb
|
||||
default:
|
||||
return retrievalSourceKnowledge
|
||||
}
|
||||
}
|
||||
|
||||
func retrievalResults(chatManage *types.ChatManage) []*types.SearchResult {
|
||||
switch {
|
||||
case len(chatManage.MergeResult) > 0:
|
||||
return chatManage.MergeResult
|
||||
case len(chatManage.RerankResult) > 0:
|
||||
return chatManage.RerankResult
|
||||
default:
|
||||
return chatManage.SearchResult
|
||||
}
|
||||
}
|
||||
|
||||
func retrievalResultBreakdown(chatManage *types.ChatManage) (total, docCount, webCount int) {
|
||||
for _, result := range retrievalResults(chatManage) {
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if isWebSearchResult(result) {
|
||||
webCount++
|
||||
} else {
|
||||
docCount++
|
||||
}
|
||||
}
|
||||
return total, docCount, webCount
|
||||
}
|
||||
|
||||
func isWebSearchResult(result *types.SearchResult) bool {
|
||||
if strings.EqualFold(result.ChunkType, "web_search") {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(result.KnowledgeSource, "web_search")
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/event"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type recordingEventBus struct {
|
||||
events []types.Event
|
||||
}
|
||||
|
||||
func (b *recordingEventBus) On(types.EventType, types.EventHandler) {}
|
||||
|
||||
func (b *recordingEventBus) Emit(_ context.Context, evt types.Event) error {
|
||||
b.events = append(b.events, evt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestIsConsolidatedRetrievalStage(t *testing.T) {
|
||||
cm := &types.ChatManage{}
|
||||
assert.True(t, IsConsolidatedRetrievalStage(types.CHUNK_SEARCH_PARALLEL, cm))
|
||||
assert.False(t, IsConsolidatedRetrievalStage(types.QUERY_UNDERSTAND, cm))
|
||||
assert.False(t, IsConsolidatedRetrievalStage(types.LOAD_HISTORY, cm))
|
||||
}
|
||||
|
||||
func TestLastConsolidatedRetrievalStage(t *testing.T) {
|
||||
cm := &types.ChatManage{}
|
||||
pipeline := []types.EventType{
|
||||
types.LOAD_HISTORY,
|
||||
types.QUERY_UNDERSTAND,
|
||||
types.CHUNK_SEARCH_PARALLEL,
|
||||
types.CHUNK_RERANK,
|
||||
types.CHUNK_MERGE,
|
||||
types.FILTER_TOP_K,
|
||||
types.INTO_CHAT_MESSAGE,
|
||||
types.CHAT_COMPLETION_STREAM,
|
||||
}
|
||||
assert.Equal(t, types.FILTER_TOP_K, LastConsolidatedRetrievalStage(pipeline, cm))
|
||||
}
|
||||
|
||||
func TestShouldCloseRetrievalProgress(t *testing.T) {
|
||||
last := types.FILTER_TOP_K
|
||||
|
||||
// Normal completion: only the last retrieval stage closes the window.
|
||||
assert.True(t, ShouldCloseRetrievalProgress(types.FILTER_TOP_K, last, nil))
|
||||
assert.False(t, ShouldCloseRetrievalProgress(types.CHUNK_SEARCH_PARALLEL, last, nil))
|
||||
|
||||
// ErrSearchNothing at an earlier retrieval stage must still close the
|
||||
// window so the frontend stops spinning before the fallback answer streams.
|
||||
assert.True(t, ShouldCloseRetrievalProgress(types.CHUNK_SEARCH_PARALLEL, last, ErrSearchNothing))
|
||||
|
||||
// A hard error at any retrieval stage must also close the window.
|
||||
assert.True(t, ShouldCloseRetrievalProgress(types.CHUNK_RERANK, last, &PluginError{}))
|
||||
}
|
||||
|
||||
func TestShouldEmitQueryUnderstandProgress(t *testing.T) {
|
||||
cm := &types.ChatManage{PipelineRequest: types.PipelineRequest{EnableRewrite: true}}
|
||||
assert.True(t, ShouldEmitQueryUnderstandProgress(cm))
|
||||
|
||||
cm.EnableRewrite = false
|
||||
assert.False(t, ShouldEmitQueryUnderstandProgress(cm))
|
||||
|
||||
cm.Images = []string{"data:image/png;base64,abc"}
|
||||
assert.True(t, ShouldEmitQueryUnderstandProgress(cm))
|
||||
}
|
||||
|
||||
func TestQueryUnderstandProgressEmitsToolCallAndResult(t *testing.T) {
|
||||
bus := &recordingEventBus{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{SessionID: "sess-1", EnableRewrite: true},
|
||||
PipelineContext: types.PipelineContext{EventBus: bus},
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
progress := BeginQueryUnderstandProgress(context.Background(), cm)
|
||||
require.NotNil(t, progress)
|
||||
EndQueryUnderstandProgress(context.Background(), cm, progress, start, nil)
|
||||
|
||||
require.Len(t, bus.events, 2)
|
||||
callData, ok := bus.events[0].Data.(event.AgentToolCallData)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "query_understand", callData.ToolName)
|
||||
}
|
||||
|
||||
func TestRetrievalProgressEmitsSingleToolCallAndResult(t *testing.T) {
|
||||
bus := &recordingEventBus{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{SessionID: "sess-1"},
|
||||
PipelineContext: types.PipelineContext{EventBus: bus},
|
||||
PipelineState: types.PipelineState{
|
||||
MergeResult: []*types.SearchResult{{ID: "r1"}, {ID: "r2"}, {ID: "r3"}},
|
||||
},
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
progress := BeginRetrievalProgress(context.Background(), cm)
|
||||
require.NotNil(t, progress)
|
||||
EndRetrievalProgress(context.Background(), cm, progress, start, nil)
|
||||
|
||||
require.Len(t, bus.events, 2)
|
||||
assert.Equal(t, types.EventType(event.EventAgentToolCall), bus.events[0].Type)
|
||||
assert.Equal(t, types.EventType(event.EventAgentToolResult), bus.events[1].Type)
|
||||
|
||||
callData, ok := bus.events[0].Data.(event.AgentToolCallData)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "knowledge_search", callData.ToolName)
|
||||
assert.Equal(t, retrievalSourceKnowledge, callData.Arguments["search_source"])
|
||||
|
||||
resultData, ok := bus.events[1].Data.(event.AgentToolResultData)
|
||||
require.True(t, ok)
|
||||
assert.True(t, resultData.Success)
|
||||
assert.Equal(t, 3, resultData.Data["count"])
|
||||
assert.Equal(t, 3, resultData.Data["doc_count"])
|
||||
assert.Equal(t, 0, resultData.Data["web_count"])
|
||||
assert.Equal(t, retrievalSourceKnowledge, resultData.Data["search_source"])
|
||||
}
|
||||
|
||||
// A turn whose candidates all fell below the relevance threshold answers from
|
||||
// the fallback, which never sees a retrieved chunk. Reporting the raw hits as
|
||||
// the result count claimed context the answer did not have — and offered a row
|
||||
// with no references behind it.
|
||||
func TestRetrievalProgressReportsNoResultsWhenPipelineFellBack(t *testing.T) {
|
||||
bus := &recordingEventBus{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{SessionID: "sess-fallback"},
|
||||
PipelineContext: types.PipelineContext{EventBus: bus},
|
||||
PipelineState: types.PipelineState{
|
||||
SearchResult: []*types.SearchResult{{ID: "r1"}, {ID: "r2"}, {ID: "r3"}},
|
||||
},
|
||||
}
|
||||
|
||||
progress := BeginRetrievalProgress(context.Background(), cm)
|
||||
require.NotNil(t, progress)
|
||||
EndRetrievalProgress(context.Background(), cm, progress, time.Now(), ErrSearchNothing)
|
||||
|
||||
resultData, ok := bus.events[1].Data.(event.AgentToolResultData)
|
||||
require.True(t, ok)
|
||||
assert.True(t, resultData.Success)
|
||||
assert.Equal(t, 0, resultData.Data["count"])
|
||||
assert.Equal(t, 0, resultData.Data["doc_count"])
|
||||
assert.Equal(t, 0, resultData.Data["web_count"])
|
||||
assert.Equal(t, 3, resultData.Data["candidate_count"])
|
||||
assert.Equal(t, "命中 3 条候选,相关性不足,未用于回答", resultData.Output)
|
||||
}
|
||||
|
||||
func TestRetrievalProgressReportsNothingFoundWhenThereWereNoCandidates(t *testing.T) {
|
||||
bus := &recordingEventBus{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{SessionID: "sess-empty"},
|
||||
PipelineContext: types.PipelineContext{EventBus: bus},
|
||||
}
|
||||
|
||||
progress := BeginRetrievalProgress(context.Background(), cm)
|
||||
require.NotNil(t, progress)
|
||||
EndRetrievalProgress(context.Background(), cm, progress, time.Now(), ErrSearchNothing)
|
||||
|
||||
resultData, ok := bus.events[1].Data.(event.AgentToolResultData)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 0, resultData.Data["count"])
|
||||
assert.Equal(t, 0, resultData.Data["candidate_count"])
|
||||
assert.Equal(t, "未检索到相关内容", resultData.Output)
|
||||
}
|
||||
|
||||
func TestRetrievalProgressWebOnlySearchSource(t *testing.T) {
|
||||
bus := &recordingEventBus{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SessionID: "sess-web",
|
||||
WebSearchEnabled: true,
|
||||
},
|
||||
PipelineContext: types.PipelineContext{EventBus: bus},
|
||||
PipelineState: types.PipelineState{
|
||||
MergeResult: []*types.SearchResult{
|
||||
{ID: "w1", ChunkType: "web_search"},
|
||||
{ID: "w2", KnowledgeSource: "web_search"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
progress := BeginRetrievalProgress(context.Background(), cm)
|
||||
require.NotNil(t, progress)
|
||||
EndRetrievalProgress(context.Background(), cm, progress, start, nil)
|
||||
|
||||
callData, ok := bus.events[0].Data.(event.AgentToolCallData)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, retrievalSourceWeb, callData.Arguments["search_source"])
|
||||
|
||||
resultData, ok := bus.events[1].Data.(event.AgentToolResultData)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 2, resultData.Data["count"])
|
||||
assert.Equal(t, 0, resultData.Data["doc_count"])
|
||||
assert.Equal(t, 2, resultData.Data["web_count"])
|
||||
assert.Equal(t, retrievalSourceWeb, resultData.Data["search_source"])
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// runQueryExpansion performs query expansion when initial recall is low.
|
||||
// It generates query variants and runs concurrent retrieval across search targets.
|
||||
func (p *PluginSearch) runQueryExpansion(ctx context.Context, chatManage *types.ChatManage) []*types.SearchResult {
|
||||
pipelineInfo(ctx, "Search", "recall_low", map[string]interface{}{
|
||||
"current": len(chatManage.SearchResult),
|
||||
"threshold": chatManage.EmbeddingTopK,
|
||||
})
|
||||
expansions := p.expandQueries(ctx, chatManage)
|
||||
if len(expansions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Search", "expansion_start", map[string]interface{}{
|
||||
"variants": len(expansions),
|
||||
})
|
||||
expTopK := max(chatManage.EmbeddingTopK*2, chatManage.RerankTopK*2)
|
||||
expKwTh := chatManage.KeywordThreshold * 0.8
|
||||
|
||||
// Concurrent expansion retrieval across queries and search targets
|
||||
expResults := make([]*types.SearchResult, 0, expTopK*len(expansions))
|
||||
var muExp sync.Mutex
|
||||
var wgExp sync.WaitGroup
|
||||
jobs := len(expansions) * len(chatManage.SearchTargets)
|
||||
capSem := 16
|
||||
if jobs < capSem {
|
||||
capSem = jobs
|
||||
}
|
||||
if capSem <= 0 {
|
||||
capSem = 1
|
||||
}
|
||||
sem := make(chan struct{}, capSem)
|
||||
pipelineInfo(ctx, "Search", "expansion_concurrency", map[string]interface{}{
|
||||
"jobs": jobs,
|
||||
"cap": capSem,
|
||||
})
|
||||
for _, q := range expansions {
|
||||
for _, target := range chatManage.SearchTargets {
|
||||
if target == nil || target.KnowledgeBaseID == "" {
|
||||
continue
|
||||
}
|
||||
wgExp.Add(1)
|
||||
go func(q string, t *types.SearchTarget) {
|
||||
defer wgExp.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
vectorThreshold, keywordThreshold := t.RecallThresholds(
|
||||
chatManage.VectorThreshold,
|
||||
expKwTh,
|
||||
)
|
||||
paramsExp := types.SearchParams{
|
||||
QueryText: q,
|
||||
VectorThreshold: vectorThreshold,
|
||||
KeywordThreshold: keywordThreshold,
|
||||
MatchCount: expTopK,
|
||||
TagIDs: t.TagIDs,
|
||||
ScopeTagIDs: t.ScopeTagIDs,
|
||||
DisableVectorMatch: false,
|
||||
DisableKeywordsMatch: false,
|
||||
SkipContextEnrichment: true, // Pipeline handles context assembly in merge stage
|
||||
}
|
||||
// Apply knowledge ID filter if this is a partial KB search
|
||||
if t.Type == types.SearchTargetTypeKnowledge {
|
||||
paramsExp.KnowledgeIDs = t.KnowledgeIDs
|
||||
}
|
||||
res, err := p.knowledgeBaseService.HybridSearch(ctx, t.KnowledgeBaseID, paramsExp)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Search", "expansion_error", map[string]interface{}{
|
||||
"kb_id": t.KnowledgeBaseID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(res) > 0 {
|
||||
for _, r := range res {
|
||||
r.KnowledgeBaseID = t.KnowledgeBaseID
|
||||
}
|
||||
pipelineInfo(ctx, "Search", "expansion_hits", map[string]interface{}{
|
||||
"kb_id": t.KnowledgeBaseID,
|
||||
"query": q,
|
||||
"hits": len(res),
|
||||
})
|
||||
muExp.Lock()
|
||||
expResults = append(expResults, res...)
|
||||
muExp.Unlock()
|
||||
}
|
||||
}(q, target)
|
||||
}
|
||||
}
|
||||
wgExp.Wait()
|
||||
|
||||
if len(expResults) > 0 {
|
||||
pipelineInfo(ctx, "Search", "expansion_done", map[string]interface{}{
|
||||
"added": len(expResults),
|
||||
})
|
||||
}
|
||||
return expResults
|
||||
}
|
||||
|
||||
// expandQueries generates query variants locally without LLM to improve keyword recall.
|
||||
// Uses simple techniques: word reordering, stopword removal, key phrase extraction.
|
||||
func (p *PluginSearch) expandQueries(ctx context.Context, chatManage *types.ChatManage) []string {
|
||||
query := strings.TrimSpace(chatManage.RewriteQuery)
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
expansions := make([]string, 0, 5)
|
||||
seen := make(map[string]struct{})
|
||||
seen[strings.ToLower(query)] = struct{}{}
|
||||
if q := strings.ToLower(chatManage.Query); q != "" {
|
||||
seen[q] = struct{}{}
|
||||
}
|
||||
|
||||
addIfNew := func(s string) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || len(s) < 3 {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(s)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
expansions = append(expansions, s)
|
||||
}
|
||||
|
||||
// 1. Remove common stopwords and create keyword-only variant
|
||||
keywords := extractKeywords(query)
|
||||
if len(keywords) >= 2 {
|
||||
addIfNew(strings.Join(keywords, " "))
|
||||
}
|
||||
|
||||
// 2. Extract quoted phrases or key segments
|
||||
phrases := extractPhrases(query)
|
||||
for _, phrase := range phrases {
|
||||
addIfNew(phrase)
|
||||
}
|
||||
|
||||
// 3. Split by common delimiters and use longest segment
|
||||
segments := splitByDelimiters(query)
|
||||
for _, seg := range segments {
|
||||
if len(seg) > 5 {
|
||||
addIfNew(seg)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Remove question words (什么/如何/怎么/为什么/哪个 etc.)
|
||||
cleaned := removeQuestionWords(query)
|
||||
if cleaned != query {
|
||||
addIfNew(cleaned)
|
||||
}
|
||||
|
||||
// Limit to 5 expansions
|
||||
if len(expansions) > 5 {
|
||||
expansions = expansions[:5]
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Search", "local_expansion_result", map[string]interface{}{
|
||||
"variants": len(expansions),
|
||||
})
|
||||
return expansions
|
||||
}
|
||||
|
||||
// Common Chinese and English stopwords
|
||||
var stopwords = map[string]struct{}{
|
||||
"的": {}, "是": {}, "在": {}, "了": {}, "和": {}, "与": {}, "或": {},
|
||||
"a": {}, "an": {}, "the": {}, "is": {}, "are": {}, "was": {}, "were": {},
|
||||
"be": {}, "been": {}, "being": {}, "have": {}, "has": {}, "had": {},
|
||||
"do": {}, "does": {}, "did": {}, "will": {}, "would": {}, "could": {},
|
||||
"should": {}, "may": {}, "might": {}, "must": {}, "can": {},
|
||||
"to": {}, "of": {}, "in": {}, "for": {}, "on": {}, "with": {}, "at": {},
|
||||
"by": {}, "from": {}, "as": {}, "into": {}, "through": {}, "about": {},
|
||||
"what": {}, "how": {}, "why": {}, "when": {}, "where": {}, "which": {},
|
||||
"who": {}, "whom": {}, "whose": {},
|
||||
}
|
||||
|
||||
// Question words in Chinese
|
||||
var questionWords = regexp.MustCompile(`^(什么是|什么|如何|怎么|怎样|为什么|为何|哪个|哪些|谁|何时|何地|请问|请告诉我|帮我|我想知道|我想了解)`)
|
||||
|
||||
func extractKeywords(text string) []string {
|
||||
words := tokenize(text)
|
||||
keywords := make([]string, 0, len(words))
|
||||
for _, w := range words {
|
||||
lower := strings.ToLower(w)
|
||||
if _, isStop := stopwords[lower]; !isStop && utf8.RuneCountInString(w) > 1 {
|
||||
keywords = append(keywords, w)
|
||||
}
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
|
||||
func extractPhrases(text string) []string {
|
||||
// Extract quoted content
|
||||
var phrases []string
|
||||
re := regexp.MustCompile(`["'"'「」『』]([^"'"'「」『』]+)["'"'「」『』]`)
|
||||
matches := re.FindAllStringSubmatch(text, -1)
|
||||
for _, m := range matches {
|
||||
if len(m) > 1 && len(m[1]) > 2 {
|
||||
phrases = append(phrases, m[1])
|
||||
}
|
||||
}
|
||||
return phrases
|
||||
}
|
||||
|
||||
func splitByDelimiters(text string) []string {
|
||||
// Split by common delimiters
|
||||
re := regexp.MustCompile(`[,,;;、。!?!?\s]+`)
|
||||
parts := re.Split(text, -1)
|
||||
var result []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func removeQuestionWords(text string) string {
|
||||
return strings.TrimSpace(questionWords.ReplaceAllString(text, ""))
|
||||
}
|
||||
|
||||
func tokenize(text string) []string {
|
||||
var tokens []string
|
||||
var current strings.Builder
|
||||
currentIsHan := false
|
||||
|
||||
flush := func() {
|
||||
if current.Len() == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if currentIsHan {
|
||||
// Use the existing search-mode dictionary for continuous Chinese text.
|
||||
for _, word := range types.Jieba.CutForSearch(current.String(), true) {
|
||||
word = strings.TrimSpace(word)
|
||||
if word != "" {
|
||||
tokens = append(tokens, word)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tokens = append(tokens, current.String())
|
||||
}
|
||||
|
||||
current.Reset()
|
||||
currentIsHan = false
|
||||
}
|
||||
|
||||
for _, r := range text {
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
if current.Len() > 0 && !currentIsHan {
|
||||
flush()
|
||||
}
|
||||
currentIsHan = true
|
||||
current.WriteRune(r)
|
||||
} else if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
if current.Len() > 0 && currentIsHan {
|
||||
flush()
|
||||
}
|
||||
currentIsHan = false
|
||||
current.WriteRune(r)
|
||||
} else {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return tokens
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestExtractKeywordsSegmentsChineseQuery(t *testing.T) {
|
||||
keywords := extractKeywords("如何配置向量数据库")
|
||||
|
||||
assert.NotContains(t, keywords, "如何配置向量数据库")
|
||||
assert.Contains(t, keywords, "配置")
|
||||
assert.Contains(t, keywords, "向量")
|
||||
assert.Contains(t, keywords, "数据库")
|
||||
}
|
||||
|
||||
func TestTokenizePreservesMixedLanguageBoundaries(t *testing.T) {
|
||||
tokens := tokenize("RAG如何配置PostgreSQL")
|
||||
|
||||
assert.Contains(t, tokens, "RAG")
|
||||
assert.Contains(t, tokens, "配置")
|
||||
assert.Contains(t, tokens, "PostgreSQL")
|
||||
assert.NotContains(t, tokens, "RAG如何配置")
|
||||
}
|
||||
|
||||
func TestExtractKeywordsDropsSingleRuneChineseTokens(t *testing.T) {
|
||||
keywords := extractKeywords("他来到了网易杭研大厦")
|
||||
|
||||
assert.Contains(t, keywords, "网易")
|
||||
assert.Contains(t, keywords, "大厦")
|
||||
for _, keyword := range keywords {
|
||||
assert.Greater(t, utf8.RuneCountInString(keyword), 1, "unexpected single-rune keyword %q", keyword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandQueriesBuildsChineseKeywordVariant(t *testing.T) {
|
||||
expansions := (&PluginSearch{}).expandQueries(context.Background(), &types.ChatManage{
|
||||
PipelineState: types.PipelineState{RewriteQuery: "如何配置向量数据库"},
|
||||
})
|
||||
|
||||
var foundKeywordVariant bool
|
||||
for _, expansion := range expansions {
|
||||
fields := strings.Fields(expansion)
|
||||
if containsToken(fields, "配置") && containsToken(fields, "向量") && containsToken(fields, "数据库") {
|
||||
foundKeywordVariant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, foundKeywordVariant, "expected a Chinese keyword expansion with segmented terms, got %v", expansions)
|
||||
}
|
||||
|
||||
func containsToken(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginQueryUnderstand performs query rewriting and intent classification.
|
||||
// It uses conversation history and an LLM to optimise the user's original query
|
||||
// and determine the downstream pipeline behaviour.
|
||||
type PluginQueryUnderstand struct {
|
||||
modelService interfaces.ModelService
|
||||
messageService interfaces.MessageService
|
||||
memoryService interfaces.MemoryService
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
var rewriteImageSepPattern = regexp.MustCompile(`(?s)^(.*?)\s*\n?---\n(.*)$`)
|
||||
|
||||
type queryUnderstandOutput struct {
|
||||
RewriteQuery string `json:"rewrite_query"`
|
||||
Intent types.QueryIntent `json:"intent"`
|
||||
ImageDescription string `json:"image_description"`
|
||||
}
|
||||
|
||||
// NewPluginQueryUnderstand creates a new query-understanding plugin instance
|
||||
// and registers it with the event manager.
|
||||
func NewPluginQueryUnderstand(eventManager *EventManager,
|
||||
modelService interfaces.ModelService, messageService interfaces.MessageService,
|
||||
memoryService interfaces.MemoryService,
|
||||
config *config.Config,
|
||||
) *PluginQueryUnderstand {
|
||||
res := &PluginQueryUnderstand{
|
||||
modelService: modelService,
|
||||
messageService: messageService,
|
||||
memoryService: memoryService,
|
||||
config: config,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the list of event types this plugin responds to.
|
||||
func (p *PluginQueryUnderstand) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.QUERY_UNDERSTAND}
|
||||
}
|
||||
|
||||
// OnEvent processes triggered events.
|
||||
// Handles three input combinations:
|
||||
// - Text only: standard rewrite + intent classification (uses chat model)
|
||||
// - Text + images: multimodal rewrite + intent + image description (uses VLM/vision model)
|
||||
// - Images only: multimodal analysis + intent + image description (uses VLM/vision model)
|
||||
func (p *PluginQueryUnderstand) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
chatManage.RewriteQuery = chatManage.Query
|
||||
|
||||
hasImages := len(chatManage.Images) > 0
|
||||
needRewrite := chatManage.EnableRewrite
|
||||
if !needRewrite && !hasImages {
|
||||
pipelineInfo(ctx, "QueryUnderstand", "skip", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"reason": "rewrite_disabled_no_images",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "QueryUnderstand", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"tenant_id": chatManage.TenantID,
|
||||
"user_query": chatManage.Query,
|
||||
"has_images": hasImages,
|
||||
"enable_rewrite": chatManage.EnableRewrite,
|
||||
})
|
||||
|
||||
// --- Load and prepare conversation history ---
|
||||
var historyList []*types.History
|
||||
if len(chatManage.History) > 0 {
|
||||
historyList = chatManage.History
|
||||
pipelineInfo(ctx, "QueryUnderstand", "history_reused", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"rounds": len(historyList),
|
||||
})
|
||||
} else {
|
||||
historyList = p.loadHistory(ctx, chatManage)
|
||||
}
|
||||
|
||||
// --- Select the appropriate model ---
|
||||
rewriteModel, useImages := p.selectModel(ctx, chatManage, hasImages)
|
||||
if rewriteModel == nil {
|
||||
pipelineError(ctx, "QueryUnderstand", "get_model", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
// --- Build prompts ---
|
||||
systemContent, userContent := p.buildPrompts(ctx, chatManage, historyList)
|
||||
|
||||
userMsg := chat.Message{Role: "user", Content: userContent}
|
||||
if useImages {
|
||||
userMsg.Images = chatManage.Images
|
||||
}
|
||||
|
||||
maxTokens := 150
|
||||
if useImages {
|
||||
maxTokens = 500
|
||||
}
|
||||
|
||||
// --- Call model ---
|
||||
thinking := false
|
||||
modelCtx := types.WithLLMCallMetadata(ctx, "query_rewrite", "")
|
||||
response, err := rewriteModel.Chat(modelCtx, []chat.Message{
|
||||
{Role: "system", Content: systemContent},
|
||||
userMsg,
|
||||
}, &chat.ChatOptions{
|
||||
Temperature: 0.3,
|
||||
MaxCompletionTokens: maxTokens,
|
||||
Thinking: &thinking,
|
||||
})
|
||||
if err != nil {
|
||||
pipelineError(ctx, "QueryUnderstand", "model_call", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
// --- Parse structured output ---
|
||||
p.parseOutput(chatManage, response.Content)
|
||||
|
||||
// Persist image description asynchronously — this DB write does not affect
|
||||
// the current pipeline result, so it can run in the background.
|
||||
if chatManage.ImageDescription != "" && chatManage.UserMessageID != "" {
|
||||
go p.updateUserMessageImageCaption(context.WithoutCancel(ctx), chatManage)
|
||||
}
|
||||
|
||||
// --- Apply intent-specific system prompt override ---
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
if applyIntentPromptOverride(chatManage, p.config.Conversation.IntentSystemPrompts) {
|
||||
pipelineInfo(ctx, "QueryUnderstand", "prompt_override", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"intent": chatManage.Intent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "QueryUnderstand", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"rewrite_query": chatManage.RewriteQuery,
|
||||
"intent": chatManage.Intent,
|
||||
"has_image_desc": chatManage.ImageDescription != "",
|
||||
"has_prompt_override": chatManage.SystemPromptOverride != "",
|
||||
"original_output": response.Content,
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
// updateUserMessageImageCaption writes the generated ImageDescription back to
|
||||
// the stored user message so that subsequent turns can see it in history.
|
||||
func (p *PluginQueryUnderstand) updateUserMessageImageCaption(ctx context.Context, chatManage *types.ChatManage) {
|
||||
msg, err := p.messageService.GetMessage(ctx, chatManage.SessionID, chatManage.UserMessageID)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "QueryUnderstand", "get_user_message", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_message_id": chatManage.UserMessageID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(msg.Images) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
msg.Images[0].Caption = chatManage.ImageDescription
|
||||
|
||||
if err := p.messageService.UpdateMessageImages(ctx, chatManage.SessionID, chatManage.UserMessageID, msg.Images); err != nil {
|
||||
pipelineWarn(ctx, "QueryUnderstand", "update_image_caption", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"user_message_id": chatManage.UserMessageID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// loadHistory fetches and processes conversation history for rewrite context.
|
||||
func (p *PluginQueryUnderstand) loadHistory(ctx context.Context, chatManage *types.ChatManage) []*types.History {
|
||||
// Honor the multi-turn-disabled signal: chatManage.MaxRounds == 0 is set
|
||||
// explicitly by applyAgentOverridesToChatManage when the custom agent has
|
||||
// MultiTurnEnabled=false. We must not silently fall back to the global
|
||||
// default, otherwise rewrite + image analysis would still pull old turns
|
||||
// into the context and leak through chatManage.History.
|
||||
if chatManage.MaxRounds <= 0 {
|
||||
return nil
|
||||
}
|
||||
maxRounds := chatManage.MaxRounds
|
||||
|
||||
historyList, err := loadAndProcessHistory(ctx, p.messageService, chatManage.SessionID, maxRounds, 20)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "QueryUnderstand", "history_fetch", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
chatManage.History = historyList
|
||||
|
||||
if len(historyList) > 0 {
|
||||
pipelineInfo(ctx, "QueryUnderstand", "history_ready", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"history_rounds": len(historyList),
|
||||
})
|
||||
}
|
||||
|
||||
return historyList
|
||||
}
|
||||
|
||||
// selectModel picks the model for query understanding. When images are present
|
||||
// it prefers a vision-capable model. Returns (model, useImages).
|
||||
func (p *PluginQueryUnderstand) selectModel(ctx context.Context, chatManage *types.ChatManage, hasImages bool) (chat.Chat, bool) {
|
||||
if hasImages {
|
||||
if chatManage.ChatModelSupportsVision {
|
||||
m, err := p.modelService.GetChatModel(ctx, chatManage.ChatModelID)
|
||||
if err == nil {
|
||||
return m, true
|
||||
}
|
||||
pipelineWarn(ctx, "QueryUnderstand", "vision_model_fallback", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
if chatManage.VLMModelID != "" {
|
||||
m, err := p.modelService.GetChatModel(ctx, chatManage.VLMModelID)
|
||||
if err == nil {
|
||||
return m, true
|
||||
}
|
||||
pipelineWarn(ctx, "QueryUnderstand", "vlm_model_fallback", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"vlm_model_id": chatManage.VLMModelID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
pipelineWarn(ctx, "QueryUnderstand", "no_vision_model", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
}
|
||||
|
||||
textModelID := chatManage.ChatModelID
|
||||
if chatManage.QueryUnderstandModelID != "" {
|
||||
textModelID = chatManage.QueryUnderstandModelID
|
||||
}
|
||||
m, err := p.modelService.GetChatModel(ctx, textModelID)
|
||||
if err != nil {
|
||||
// Fall back to ChatModelID when a dedicated query-understand model was
|
||||
// configured but cannot be resolved (e.g. deleted / disabled).
|
||||
if chatManage.QueryUnderstandModelID != "" && textModelID != chatManage.ChatModelID {
|
||||
pipelineWarn(ctx, "QueryUnderstand", "query_understand_model_fallback", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"query_understand_model_id": chatManage.QueryUnderstandModelID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
if fallback, fbErr := p.modelService.GetChatModel(ctx, chatManage.ChatModelID); fbErr == nil {
|
||||
return fallback, false
|
||||
}
|
||||
}
|
||||
pipelineError(ctx, "QueryUnderstand", "get_model", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"chat_model_id": textModelID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
return m, false
|
||||
}
|
||||
|
||||
// buildPrompts constructs system and user prompts with placeholder replacement.
|
||||
func (p *PluginQueryUnderstand) buildPrompts(
|
||||
ctx context.Context, chatManage *types.ChatManage, historyList []*types.History,
|
||||
) (string, string) {
|
||||
userPrompt := p.config.Conversation.RewritePromptUser
|
||||
if chatManage.RewritePromptUser != "" {
|
||||
userPrompt = chatManage.RewritePromptUser
|
||||
}
|
||||
systemPrompt := p.config.Conversation.RewritePromptSystem
|
||||
if chatManage.RewritePromptSystem != "" {
|
||||
systemPrompt = chatManage.RewritePromptSystem
|
||||
}
|
||||
|
||||
conversationText := formatConversationHistory(historyList)
|
||||
|
||||
queryContent := chatManage.Query
|
||||
if len(chatManage.Images) > 0 {
|
||||
queryContent += fmt.Sprintf("\n\n<images_uploaded count=\"%d\" />", len(chatManage.Images))
|
||||
} else {
|
||||
queryContent += "\n\n<no_image_attached />"
|
||||
}
|
||||
if len(chatManage.Attachments) > 0 {
|
||||
queryContent += chatManage.Attachments.BuildPrompt()
|
||||
} else {
|
||||
queryContent += "\n<no_document_attached />"
|
||||
}
|
||||
queryContent += p.memoryBackground(ctx, chatManage)
|
||||
|
||||
vals := types.PlaceholderValues{
|
||||
"conversation": conversationText,
|
||||
"query": queryContent,
|
||||
"language": chatManage.Language,
|
||||
}
|
||||
|
||||
return types.RenderPromptPlaceholders(systemPrompt, vals),
|
||||
types.RenderPromptPlaceholders(userPrompt, vals)
|
||||
}
|
||||
|
||||
// memoryBackground gives the rewriter who is asking.
|
||||
//
|
||||
// This is the point where long-term memory stops being a paragraph appended to
|
||||
// the answer prompt and starts changing what gets retrieved. "How do I tune the
|
||||
// segmentation" is a different search for someone who works on medical imaging
|
||||
// than for someone who works on autonomous driving, and the only place that
|
||||
// difference can be applied is before retrieval runs.
|
||||
//
|
||||
// It is deliberately advisory rather than a filter. Memory narrows nothing and
|
||||
// excludes no knowledge base: a stale note about last quarter's project must
|
||||
// not be able to make this quarter's documents unreachable.
|
||||
func (p *PluginQueryUnderstand) memoryBackground(ctx context.Context, chatManage *types.ChatManage) string {
|
||||
if p.memoryService == nil {
|
||||
return ""
|
||||
}
|
||||
memCtx := p.memoryService.RetrievalContextFor(ctx)
|
||||
if memCtx.Empty() {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n<asker_background note=\"背景仅用于消解指代和补全检索词,不要当作问题的一部分\">")
|
||||
if memCtx.Background != "" {
|
||||
b.WriteString("\n" + memCtx.Background)
|
||||
}
|
||||
if len(memCtx.Interests) > 0 {
|
||||
b.WriteString("\n长期关注:" + strings.Join(memCtx.Interests, "、"))
|
||||
}
|
||||
if len(memCtx.Documents) > 0 {
|
||||
b.WriteString("\n常查资料:" + strings.Join(memCtx.Documents, "、"))
|
||||
}
|
||||
b.WriteString("\n</asker_background>")
|
||||
|
||||
// Deliberately does not add to chatManage.UsedMemories. What this reads is
|
||||
// the whole standing background, unfiltered — that is the right input for a
|
||||
// rewriter, but reporting it would claim every turn recalled memories that
|
||||
// have nothing to do with the question. Which memories this turn actually
|
||||
// used is decided in MEMORY_RECALL, by relevance, and the profile entries
|
||||
// here are already reported from there.
|
||||
fields := map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"interests": len(memCtx.Interests),
|
||||
"documents": len(memCtx.Documents),
|
||||
"items": len(memCtx.Items),
|
||||
}
|
||||
if len(memCtx.Interests) > 0 {
|
||||
fields["interest_previews"] = memCtx.Interests
|
||||
}
|
||||
pipelineInfo(ctx, "QueryUnderstand", "memory_background", fields)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseOutput extracts the rewritten query, intent classification, and optional
|
||||
// image description from the model's structured JSON output.
|
||||
//
|
||||
// Expected format: {"rewrite_query":"...","intent":"kb_search","image_description":"..."}
|
||||
func (p *PluginQueryUnderstand) parseOutput(chatManage *types.ChatManage, raw string) {
|
||||
content := strings.TrimSpace(raw)
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if output, ok := parseStructuredQueryOutput(content); ok {
|
||||
if rewrite := strings.TrimSpace(output.RewriteQuery); rewrite != "" {
|
||||
chatManage.RewriteQuery = rewrite
|
||||
}
|
||||
chatManage.Intent = output.Intent
|
||||
chatManage.ImageDescription = strings.TrimSpace(output.ImageDescription)
|
||||
return
|
||||
}
|
||||
|
||||
// On parse failure, keep the original query and intent.
|
||||
}
|
||||
|
||||
func parseStructuredQueryOutput(raw string) (queryUnderstandOutput, bool) {
|
||||
content := strings.TrimSpace(raw)
|
||||
if content == "" {
|
||||
return queryUnderstandOutput{}, false
|
||||
}
|
||||
|
||||
if parsed, ok := parseStructuredQueryOutputJSON(content); ok {
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// Be tolerant to occasional markdown wrappers or extra prose.
|
||||
start := strings.Index(content, "{")
|
||||
end := strings.LastIndex(content, "}")
|
||||
if start < 0 || end <= start {
|
||||
return queryUnderstandOutput{}, false
|
||||
}
|
||||
candidate := content[start : end+1]
|
||||
if parsed, ok := parseStructuredQueryOutputJSON(candidate); ok {
|
||||
return parsed, true
|
||||
}
|
||||
return queryUnderstandOutput{}, false
|
||||
}
|
||||
|
||||
func parseStructuredQueryOutputJSON(content string) (queryUnderstandOutput, bool) {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(content), &obj); err != nil {
|
||||
return queryUnderstandOutput{}, false
|
||||
}
|
||||
|
||||
out := queryUnderstandOutput{
|
||||
RewriteQuery: strings.TrimSpace(firstStringField(obj,
|
||||
"rewrite_query", "rewritten_query", "query", "question")),
|
||||
}
|
||||
|
||||
intentStr := strings.TrimSpace(firstStringField(obj, "intent"))
|
||||
if intentStr != "" {
|
||||
out.Intent = types.QueryIntent(intentStr)
|
||||
}
|
||||
|
||||
desc := strings.TrimSpace(firstStringField(obj,
|
||||
"image_description", "image_desc", "image_text", "image_ocr_text", "description"))
|
||||
ocr := strings.TrimSpace(firstStringField(obj,
|
||||
"ocr_text", "ocr", "full_ocr", "image_ocr", "ocr_content"))
|
||||
combined, set := mergeImageDescAndOCR(desc, ocr)
|
||||
if set {
|
||||
out.ImageDescription = combined
|
||||
}
|
||||
|
||||
return out, true
|
||||
}
|
||||
|
||||
func firstStringField(obj map[string]json.RawMessage, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
raw, ok := obj[key]
|
||||
if !ok || len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mergeImageDescAndOCR(desc, ocr string) (string, bool) {
|
||||
if desc == "" && ocr == "" {
|
||||
return "", false
|
||||
}
|
||||
if desc == "" {
|
||||
return ocr, true
|
||||
}
|
||||
if ocr == "" {
|
||||
return desc, true
|
||||
}
|
||||
if strings.Contains(desc, ocr) {
|
||||
return desc, true
|
||||
}
|
||||
return desc + "\n\n[OCR]\n" + ocr, true
|
||||
}
|
||||
|
||||
// applyIntentPromptOverride resolves the system-prompt override for the current
|
||||
// non-retrieval intent. Agent-level overrides take precedence; otherwise the
|
||||
// tenant/global IntentSystemPrompts map is consulted. Whitespace-only agent
|
||||
// overrides are treated as unset and fall through to the global default. Returns
|
||||
// true when a non-empty override was applied.
|
||||
func applyIntentPromptOverride(chatManage *types.ChatManage, globalPrompts map[string]string) bool {
|
||||
intentKey := string(chatManage.Intent)
|
||||
if raw, ok := chatManage.IntentPromptOverrides[intentKey]; ok && strings.TrimSpace(raw) != "" {
|
||||
chatManage.SystemPromptOverride = raw
|
||||
}
|
||||
if chatManage.SystemPromptOverride == "" {
|
||||
if prompt, ok := globalPrompts[intentKey]; ok {
|
||||
chatManage.SystemPromptOverride = prompt
|
||||
}
|
||||
}
|
||||
return chatManage.SystemPromptOverride != ""
|
||||
}
|
||||
|
||||
// formatConversationHistory formats conversation history for prompt template.
|
||||
func formatConversationHistory(historyList []*types.History) string {
|
||||
if len(historyList) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for _, h := range historyList {
|
||||
builder.WriteString("------BEGIN------\n")
|
||||
builder.WriteString("User question: ")
|
||||
builder.WriteString(h.Query)
|
||||
builder.WriteString("\nAssistant answer: ")
|
||||
builder.WriteString(h.Answer)
|
||||
builder.WriteString("\n------END------\n")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestApplyIntentPromptOverride_AgentOverrideWins(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
IntentPromptOverrides: map[string]string{"chitchat": "agent prompt"},
|
||||
},
|
||||
PipelineState: types.PipelineState{Intent: types.IntentChitchat},
|
||||
}
|
||||
global := map[string]string{"chitchat": "global prompt"}
|
||||
|
||||
if !applyIntentPromptOverride(cm, global) {
|
||||
t.Fatal("expected applied=true")
|
||||
}
|
||||
if cm.SystemPromptOverride != "agent prompt" {
|
||||
t.Errorf("override: got %q, want %q", cm.SystemPromptOverride, "agent prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIntentPromptOverride_PreservesAgentWhitespace(t *testing.T) {
|
||||
// Agent-supplied prompts with surrounding whitespace must reach the model
|
||||
// verbatim; trim is only used for emptiness detection.
|
||||
raw := " agent prompt with trailing newline\n"
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
IntentPromptOverrides: map[string]string{"chitchat": raw},
|
||||
},
|
||||
PipelineState: types.PipelineState{Intent: types.IntentChitchat},
|
||||
}
|
||||
|
||||
if !applyIntentPromptOverride(cm, nil) {
|
||||
t.Fatal("expected applied=true")
|
||||
}
|
||||
if cm.SystemPromptOverride != raw {
|
||||
t.Errorf("override: got %q, want %q", cm.SystemPromptOverride, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIntentPromptOverride_BlankAgentFallsBackToGlobal(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
IntentPromptOverrides: map[string]string{"chitchat": " \n\t "},
|
||||
},
|
||||
PipelineState: types.PipelineState{Intent: types.IntentChitchat},
|
||||
}
|
||||
global := map[string]string{"chitchat": "global prompt"}
|
||||
|
||||
if !applyIntentPromptOverride(cm, global) {
|
||||
t.Fatal("expected applied=true")
|
||||
}
|
||||
if cm.SystemPromptOverride != "global prompt" {
|
||||
t.Errorf("override: got %q, want %q", cm.SystemPromptOverride, "global prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIntentPromptOverride_NoOverrideAndNoGlobal(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{Intent: types.IntentChitchat},
|
||||
}
|
||||
|
||||
if applyIntentPromptOverride(cm, nil) {
|
||||
t.Fatal("expected applied=false")
|
||||
}
|
||||
if cm.SystemPromptOverride != "" {
|
||||
t.Errorf("override should remain empty, got %q", cm.SystemPromptOverride)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIntentPromptOverride_GlobalOnly(t *testing.T) {
|
||||
cm := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{Intent: types.IntentGreeting},
|
||||
}
|
||||
global := map[string]string{"greeting": "hi there"}
|
||||
|
||||
if !applyIntentPromptOverride(cm, global) {
|
||||
t.Fatal("expected applied=true")
|
||||
}
|
||||
if cm.SystemPromptOverride != "hi there" {
|
||||
t.Errorf("override: got %q, want %q", cm.SystemPromptOverride, "hi there")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOutput_UnparsableFallsBackToOriginalQuery pins the degradation
|
||||
// contract for query understanding: when the LLM returns output that cannot be
|
||||
// parsed as the structured {"rewrite_query","intent",...} JSON, RewriteQuery
|
||||
// must remain the original user query (which OnEvent sets before calling
|
||||
// parseOutput) instead of leaking the raw model text into the downstream
|
||||
// retrieval query.
|
||||
func TestParseOutput_UnparsableFallsBackToOriginalQuery(t *testing.T) {
|
||||
p := &PluginQueryUnderstand{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{
|
||||
RewriteQuery: "original user query",
|
||||
Intent: types.IntentKBSearch,
|
||||
},
|
||||
}
|
||||
|
||||
p.parseOutput(cm, "The answer is: check the admin console")
|
||||
|
||||
if cm.RewriteQuery != "original user query" {
|
||||
t.Fatalf("RewriteQuery = %q, want original user query", cm.RewriteQuery)
|
||||
}
|
||||
if cm.Intent != types.IntentKBSearch {
|
||||
t.Errorf("Intent = %q, want kb_search", cm.Intent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOutput_UnparsableBlankDoesNotRewrite verifies that empty LLM output
|
||||
// also leaves the original query untouched.
|
||||
func TestParseOutput_UnparsableBlankDoesNotRewrite(t *testing.T) {
|
||||
p := &PluginQueryUnderstand{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{
|
||||
RewriteQuery: "original user query",
|
||||
Intent: types.IntentKBSearch,
|
||||
},
|
||||
}
|
||||
|
||||
p.parseOutput(cm, " \n\t ")
|
||||
|
||||
if cm.RewriteQuery != "original user query" {
|
||||
t.Fatalf("RewriteQuery = %q, want original user query", cm.RewriteQuery)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOutput_ValidJSONStillAppliesRewrite guards the happy path: a
|
||||
// well-formed structured output still overrides RewriteQuery and Intent.
|
||||
func TestParseOutput_ValidJSONStillAppliesRewrite(t *testing.T) {
|
||||
p := &PluginQueryUnderstand{}
|
||||
cm := &types.ChatManage{
|
||||
PipelineState: types.PipelineState{
|
||||
RewriteQuery: "original user query",
|
||||
Intent: types.IntentKBSearch,
|
||||
},
|
||||
}
|
||||
|
||||
p.parseOutput(cm, `{"rewrite_query":"rewritten query","intent":"summarize"}`)
|
||||
|
||||
if cm.RewriteQuery != "rewritten query" {
|
||||
t.Fatalf("RewriteQuery = %q, want rewritten query", cm.RewriteQuery)
|
||||
}
|
||||
if cm.Intent != types.IntentSummarize {
|
||||
t.Errorf("Intent = %q, want summarize", cm.Intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/modelcontext"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// prepareMessagesWithModelContext replaces positional retrieval IDs with
|
||||
// request-local model handles. Persisted rendered_content remains unchanged;
|
||||
// public citations are expanded only when the request setting enables them.
|
||||
func prepareMessagesWithModelContext(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
) ([]chat.Message, *modelcontext.Registry) {
|
||||
citationsEnabled := chatManage == nil || chatManage.CitationsEnabled()
|
||||
registry := modelcontext.NewRegistry(citationsEnabled)
|
||||
if chatManage == nil {
|
||||
return nil, registry
|
||||
}
|
||||
messages := prepareMessagesWithHistory(chatManage)
|
||||
if len(messages) > 0 {
|
||||
messages[0].Content = strings.TrimRight(messages[0].Content, " \t\r\n") + registry.ProtocolPrompt()
|
||||
}
|
||||
if len(chatManage.MergeResult) == 0 || len(messages) == 0 {
|
||||
return messages, registry
|
||||
}
|
||||
|
||||
ordered := orderedPipelineReferences(chatManage)
|
||||
knowledgeResults := make([]*types.SearchResult, 0, len(ordered))
|
||||
knowledgeRows := make([]map[string]interface{}, 0, len(ordered))
|
||||
webRows := make([]map[string]interface{}, 0)
|
||||
for _, result := range ordered {
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
if isPipelineWebReference(result) {
|
||||
webRows = append(webRows, map[string]interface{}{
|
||||
"url": result.ID,
|
||||
"title": firstPipelineTitle(result),
|
||||
"snippet": result.Content,
|
||||
"published_at": result.Metadata["published_at"],
|
||||
})
|
||||
continue
|
||||
}
|
||||
knowledgeResults = append(knowledgeResults, result)
|
||||
knowledgeRows = append(knowledgeRows, map[string]interface{}{
|
||||
"chunk_id": result.ID,
|
||||
"knowledge_id": result.KnowledgeID,
|
||||
"knowledge_base_id": result.KnowledgeBaseID,
|
||||
"knowledge_title": firstPipelineTitle(result),
|
||||
"chunk_index": result.ChunkIndex,
|
||||
"chunk_type": result.ChunkType,
|
||||
"content": getEnrichedPassageForChat(ctx, result),
|
||||
})
|
||||
}
|
||||
registry.RegisterSearchResults(knowledgeResults)
|
||||
var contextParts []string
|
||||
if len(knowledgeRows) > 0 {
|
||||
contextParts = append(contextParts, registry.ModelToolResult(&types.ToolResult{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"display_type": "search_results",
|
||||
"results": knowledgeRows,
|
||||
},
|
||||
}))
|
||||
}
|
||||
if len(webRows) > 0 {
|
||||
contextParts = append(contextParts, registry.ModelToolResult(&types.ToolResult{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"display_type": "web_search_results",
|
||||
"results": webRows,
|
||||
},
|
||||
}))
|
||||
}
|
||||
modelContexts := strings.Join(contextParts, "\n")
|
||||
if strings.TrimSpace(modelContexts) == "" {
|
||||
return messages, registry
|
||||
}
|
||||
|
||||
last := len(messages) - 1
|
||||
replaced := false
|
||||
for _, index := range []int{0, last} {
|
||||
if chatManage.RenderedContexts != "" && strings.Contains(messages[index].Content, chatManage.RenderedContexts) {
|
||||
messages[index].Content = strings.ReplaceAll(messages[index].Content, chatManage.RenderedContexts, modelContexts)
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
messages[last].Content = modelContexts + "\n\n" + messages[last].Content
|
||||
}
|
||||
return messages, registry
|
||||
}
|
||||
|
||||
func isPipelineWebReference(result *types.SearchResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(result.ChunkType, string(types.ChunkTypeWebSearch)) ||
|
||||
strings.EqualFold(result.KnowledgeSource, "web_search")
|
||||
}
|
||||
|
||||
func orderedPipelineReferences(chatManage *types.ChatManage) []*types.SearchResult {
|
||||
if chatManage == nil {
|
||||
return nil
|
||||
}
|
||||
if !chatManage.FAQPriorityEnabled {
|
||||
return chatManage.MergeResult
|
||||
}
|
||||
ordered := make([]*types.SearchResult, 0, len(chatManage.MergeResult))
|
||||
for _, result := range chatManage.MergeResult {
|
||||
if result != nil && result.ChunkType == string(types.ChunkTypeFAQ) {
|
||||
ordered = append(ordered, result)
|
||||
}
|
||||
}
|
||||
for _, result := range chatManage.MergeResult {
|
||||
if result != nil && result.ChunkType != string(types.ChunkTypeFAQ) {
|
||||
ordered = append(ordered, result)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func firstPipelineTitle(result *types.SearchResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
if result.KnowledgeTitle != "" {
|
||||
return result.KnowledgeTitle
|
||||
}
|
||||
return result.KnowledgeFilename
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPrepareMessagesWithModelContextUsesChunkCentricContext(t *testing.T) {
|
||||
rendered := `<context id="1">first content</context><context id="2">second content</context>`
|
||||
manage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
Query: "question",
|
||||
SummaryConfig: types.SummaryConfig{
|
||||
Prompt: "system",
|
||||
},
|
||||
},
|
||||
PipelineState: types.PipelineState{
|
||||
RenderedContexts: rendered,
|
||||
UserContent: "References:\n" + rendered + "\nQuestion: question",
|
||||
MergeResult: []*types.SearchResult{
|
||||
{ID: "chunk-1", KnowledgeID: "doc-1", KnowledgeBaseID: "kb-1", KnowledgeTitle: "Doc", ChunkIndex: 1, Content: "first content"},
|
||||
{ID: "chunk-2", KnowledgeID: "doc-1", KnowledgeBaseID: "kb-1", KnowledgeTitle: "Doc", ChunkIndex: 2, Content: "second content"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
messages, refs := prepareMessagesWithModelContext(context.Background(), manage)
|
||||
require.Len(t, messages, 2)
|
||||
require.Contains(t, messages[0].Content, "Source handling protocol")
|
||||
require.Contains(t, messages[1].Content, `<document id="d1" kb="b1" title="Doc">`)
|
||||
require.Contains(t, messages[1].Content, `<chunk id="c1" index="1" view="full">`)
|
||||
require.Contains(t, messages[1].Content, `<chunk id="c2" index="2" view="full">`)
|
||||
require.False(t, strings.Contains(messages[1].Content, "chunk-1"))
|
||||
require.Equal(t,
|
||||
`<kb doc="Doc" chunk_id="chunk-1" kb_id="kb-1" />`,
|
||||
refs.DecodeOutputText(`<ref id="c1"/>`),
|
||||
)
|
||||
}
|
||||
|
||||
func TestPrepareMessagesWithModelContextReplacesSystemPromptContextAndHistoryCitations(t *testing.T) {
|
||||
rendered := `<context id="1">first content</context>`
|
||||
manage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
Query: "question",
|
||||
SummaryConfig: types.SummaryConfig{
|
||||
Prompt: `System references: {{contexts}}`,
|
||||
},
|
||||
},
|
||||
PipelineState: types.PipelineState{
|
||||
RenderedContexts: rendered,
|
||||
UserContent: "Question: question",
|
||||
History: []*types.History{{
|
||||
Query: "previous",
|
||||
Answer: `Previous <kb doc="Old" chunk_id="old-chunk" kb_id="old-kb" />`,
|
||||
}},
|
||||
MergeResult: []*types.SearchResult{{
|
||||
ID: "current-chunk", KnowledgeID: "current-doc", KnowledgeBaseID: "current-kb", KnowledgeTitle: "Current", Content: "first content",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
messages, refs := prepareMessagesWithModelContext(context.Background(), manage)
|
||||
messages = refs.EncodeMessages(messages)
|
||||
require.NotContains(t, messages[0].Content, rendered)
|
||||
require.Contains(t, messages[0].Content, `<chunk id="c1"`)
|
||||
require.Contains(t, messages[2].Content, `<ref id="c2"/>`)
|
||||
require.NotContains(t, messages[2].Content, "old-chunk")
|
||||
}
|
||||
|
||||
func TestPrepareMessagesWithModelContextKeepsWebSeparateFromChunks(t *testing.T) {
|
||||
manage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{Query: "question", SummaryConfig: types.SummaryConfig{Prompt: "system"}},
|
||||
PipelineState: types.PipelineState{
|
||||
UserContent: "question",
|
||||
MergeResult: []*types.SearchResult{{
|
||||
ID: "https://example.com/page",
|
||||
KnowledgeTitle: "Example",
|
||||
Content: "web content",
|
||||
ChunkType: string(types.ChunkTypeWebSearch),
|
||||
KnowledgeSource: "web_search",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
messages, refs := prepareMessagesWithModelContext(context.Background(), manage)
|
||||
require.Contains(t, messages[1].Content, `<retrieval type="web" mode="search">`)
|
||||
require.Contains(t, messages[1].Content, `<page id="w1" title="Example">`)
|
||||
require.NotContains(t, messages[1].Content, `<chunk id="c1"`)
|
||||
require.Equal(t,
|
||||
`<web url="https://example.com/page" title="Example" />`,
|
||||
refs.DecodeOutputText(`<ref id="w1"/>`),
|
||||
)
|
||||
}
|
||||
|
||||
func TestPrepareMessagesWithModelContextCompactsHistoryWithoutCurrentRetrieval(t *testing.T) {
|
||||
manage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{Query: "follow-up", SummaryConfig: types.SummaryConfig{Prompt: "system"}},
|
||||
PipelineState: types.PipelineState{
|
||||
UserContent: "follow-up",
|
||||
History: []*types.History{{
|
||||
Query: "previous",
|
||||
Answer: `Previous <web url="https://example.com/old" title="Old" />`,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
messages, refs := prepareMessagesWithModelContext(context.Background(), manage)
|
||||
messages = refs.EncodeMessages(messages)
|
||||
require.Contains(t, messages[0].Content, "Source handling protocol")
|
||||
require.Contains(t, messages[2].Content, `<ref id="w1"/>`)
|
||||
require.NotContains(t, messages[2].Content, "https://example.com/old")
|
||||
}
|
||||
|
||||
func TestPrepareMessagesWithModelContextSuppressesCitationsWhenDisabled(t *testing.T) {
|
||||
disabled := false
|
||||
manage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
Query: "question",
|
||||
CitationEnabled: &disabled,
|
||||
SummaryConfig: types.SummaryConfig{Prompt: "custom system prompt"},
|
||||
},
|
||||
PipelineState: types.PipelineState{
|
||||
UserContent: "question",
|
||||
MergeResult: []*types.SearchResult{{
|
||||
ID: "chunk-1", KnowledgeID: "doc-1", KnowledgeBaseID: "kb-1", KnowledgeTitle: "Doc", Content: "evidence",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
messages, refs := prepareMessagesWithModelContext(context.Background(), manage)
|
||||
require.Contains(t, messages[0].Content, "Source citations are disabled")
|
||||
require.Contains(t, messages[1].Content, `<chunk id="c1"`)
|
||||
require.NotContains(t, messages[1].Content, "chunk-1")
|
||||
require.Equal(t, "answer ", refs.DecodeOutputText(`answer <ref id="c1"/>`))
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/rerank"
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginRerank implements reranking functionality for chat pipeline
|
||||
type PluginRerank struct {
|
||||
modelService interfaces.ModelService // Service to access rerank models
|
||||
}
|
||||
|
||||
// NewPluginRerank creates a new rerank plugin instance
|
||||
func NewPluginRerank(eventManager *EventManager, modelService interfaces.ModelService) *PluginRerank {
|
||||
res := &PluginRerank{
|
||||
modelService: modelService,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginRerank) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHUNK_RERANK}
|
||||
}
|
||||
|
||||
// OnEvent handles reranking events in the chat pipeline
|
||||
func (p *PluginRerank) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
return next()
|
||||
}
|
||||
pipelineInfo(ctx, "Rerank", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"candidate_cnt": len(chatManage.SearchResult),
|
||||
"rerank_model": chatManage.RerankModelID,
|
||||
"rerank_thresh": chatManage.RerankThreshold,
|
||||
"rewrite_query": chatManage.RewriteQuery,
|
||||
})
|
||||
if len(chatManage.SearchResult) == 0 {
|
||||
pipelineInfo(ctx, "Rerank", "skip", map[string]interface{}{
|
||||
"reason": "empty_search_result",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
if chatManage.RerankModelID == "" {
|
||||
pipelineWarn(ctx, "Rerank", "skip", map[string]interface{}{
|
||||
"reason": "empty_model_id",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
// Get rerank model from service
|
||||
rerankModel, err := p.modelService.GetRerankModel(ctx, chatManage.RerankModelID)
|
||||
if err != nil {
|
||||
pipelineError(ctx, "Rerank", "get_model", map[string]interface{}{
|
||||
"model_id": chatManage.RerankModelID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return ErrGetRerankModel.WithError(err)
|
||||
}
|
||||
|
||||
// Prepare passages for reranking.
|
||||
var passages []string
|
||||
var candidatesToRerank []*types.SearchResult
|
||||
|
||||
for _, result := range chatManage.SearchResult {
|
||||
passage := getEnrichedPassage(ctx, result)
|
||||
if strings.TrimSpace(passage) == "" {
|
||||
pipelineInfo(ctx, "Rerank", "empty_passage_skip", map[string]interface{}{
|
||||
"chunk_id": result.ID,
|
||||
})
|
||||
continue
|
||||
}
|
||||
passages = append(passages, passage)
|
||||
candidatesToRerank = append(candidatesToRerank, result)
|
||||
}
|
||||
|
||||
passagesPreview := langfuse.SummarizePassagePreviews(candidatesToRerank, passages, 25)
|
||||
rerankCtx, rerankSpan := langfuse.GetManager().StartSpan(ctx, langfuse.SpanOptions{
|
||||
Name: "rerank",
|
||||
Input: map[string]interface{}{
|
||||
"query": chatManage.RewriteQuery,
|
||||
"candidate_count": len(candidatesToRerank),
|
||||
"rerank_model_id": chatManage.RerankModelID,
|
||||
"threshold": chatManage.RerankThreshold,
|
||||
"rerank_top_k": chatManage.RerankTopK,
|
||||
"faq_priority": chatManage.FAQPriorityEnabled,
|
||||
"faq_score_boost": chatManage.FAQScoreBoost,
|
||||
"passages_preview": passagesPreview,
|
||||
},
|
||||
Metadata: map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
},
|
||||
})
|
||||
ctx = rerankCtx
|
||||
spanOutput := map[string]interface{}{}
|
||||
var spanErr error
|
||||
defer func() {
|
||||
rerankSpan.Finish(spanOutput, nil, spanErr)
|
||||
}()
|
||||
|
||||
pipelineInfo(ctx, "Rerank", "build_passages", map[string]interface{}{
|
||||
"total_cnt": len(chatManage.SearchResult),
|
||||
"candidate_cnt": len(candidatesToRerank),
|
||||
})
|
||||
|
||||
var rerankResp []rerank.RankResult
|
||||
var rawRerankResp []rerank.RankResult
|
||||
thresholdDegraded := false
|
||||
|
||||
// Only call rerank model if there are candidates
|
||||
if len(candidatesToRerank) > 0 {
|
||||
// Single rerank call with RewriteQuery, use threshold degradation if no results
|
||||
originalThreshold := chatManage.RerankThreshold
|
||||
var rerankErr error
|
||||
rerankResp, rerankErr = p.rerank(ctx, chatManage, rerankModel, chatManage.RewriteQuery, passages, candidatesToRerank)
|
||||
|
||||
if rerankErr != nil {
|
||||
// Rerank API failed — fallback to original retrieval results so the
|
||||
// pipeline can still return something useful to the caller.
|
||||
pipelineWarn(ctx, "Rerank", "api_error_fallback", map[string]interface{}{
|
||||
"error": rerankErr.Error(),
|
||||
"candidate_cnt": len(candidatesToRerank),
|
||||
})
|
||||
chatManage.SearchResult = candidatesToRerank
|
||||
spanOutput = map[string]interface{}{
|
||||
"stage": "api_error_fallback",
|
||||
"candidate_count": len(candidatesToRerank),
|
||||
"error": rerankErr.Error(),
|
||||
}
|
||||
return next()
|
||||
}
|
||||
rawRerankResp = append([]rerank.RankResult(nil), rerankResp...)
|
||||
|
||||
// If no results and threshold is high enough, try with lower threshold
|
||||
if len(rerankResp) == 0 && originalThreshold > 0.3 {
|
||||
thresholdDegraded = true
|
||||
degradedThreshold := originalThreshold * 0.7
|
||||
if degradedThreshold < 0.3 {
|
||||
degradedThreshold = 0.3
|
||||
}
|
||||
pipelineWarn(ctx, "Rerank", "threshold_degrade", map[string]interface{}{
|
||||
"original": originalThreshold,
|
||||
"degraded": degradedThreshold,
|
||||
"candidate_cnt": len(candidatesToRerank),
|
||||
"reason": "no results above original threshold, retrying with lower threshold",
|
||||
})
|
||||
chatManage.RerankThreshold = degradedThreshold
|
||||
rerankResp, rerankErr = p.rerank(ctx, chatManage, rerankModel, chatManage.RewriteQuery, passages, candidatesToRerank)
|
||||
// Restore original threshold
|
||||
chatManage.RerankThreshold = originalThreshold
|
||||
if rerankErr != nil {
|
||||
pipelineWarn(ctx, "Rerank", "api_error_fallback", map[string]interface{}{
|
||||
"error": rerankErr.Error(),
|
||||
"candidate_cnt": len(candidatesToRerank),
|
||||
})
|
||||
chatManage.SearchResult = candidatesToRerank
|
||||
spanOutput = map[string]interface{}{
|
||||
"stage": "api_error_fallback",
|
||||
"candidate_count": len(candidatesToRerank),
|
||||
"threshold_degraded": thresholdDegraded,
|
||||
"error": rerankErr.Error(),
|
||||
}
|
||||
return next()
|
||||
}
|
||||
rawRerankResp = append([]rerank.RankResult(nil), rerankResp...)
|
||||
}
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Rerank", "model_response", map[string]interface{}{
|
||||
"result_cnt": len(rerankResp),
|
||||
})
|
||||
|
||||
logRerankInputScoreSample(ctx, chatManage.SearchResult)
|
||||
|
||||
for i := range chatManage.SearchResult {
|
||||
chatManage.SearchResult[i].Metadata = ensureMetadata(chatManage.SearchResult[i].Metadata)
|
||||
}
|
||||
reranked := make([]*types.SearchResult, 0, len(rerankResp))
|
||||
|
||||
// Process reranked results
|
||||
for _, rr := range rerankResp {
|
||||
if rr.Index >= len(candidatesToRerank) {
|
||||
continue
|
||||
}
|
||||
sr := candidatesToRerank[rr.Index]
|
||||
base := sr.Score
|
||||
sr.Metadata["base_score"] = fmt.Sprintf("%.4f", base)
|
||||
modelScore := rr.RelevanceScore
|
||||
sr.Metadata["model_score"] = fmt.Sprintf("%.4f", modelScore)
|
||||
sr.Score = compositeScore(sr, modelScore, base)
|
||||
|
||||
// Apply FAQ score boost if enabled
|
||||
if chatManage.FAQPriorityEnabled && chatManage.FAQScoreBoost > 1.0 &&
|
||||
sr.ChunkType == string(types.ChunkTypeFAQ) {
|
||||
originalScore := sr.Score
|
||||
sr.Score = math.Min(sr.Score*chatManage.FAQScoreBoost, 1.0)
|
||||
sr.Metadata["faq_boosted"] = "true"
|
||||
sr.Metadata["faq_original_score"] = fmt.Sprintf("%.4f", originalScore)
|
||||
pipelineInfo(ctx, "Rerank", "faq_boost", map[string]interface{}{
|
||||
"chunk_id": sr.ID,
|
||||
"original_score": fmt.Sprintf("%.4f", originalScore),
|
||||
"boosted_score": fmt.Sprintf("%.4f", sr.Score),
|
||||
"boost_factor": chatManage.FAQScoreBoost,
|
||||
})
|
||||
}
|
||||
|
||||
reranked = append(reranked, sr)
|
||||
}
|
||||
|
||||
final := applyMMR(ctx, reranked, chatManage, min(len(reranked), max(1, chatManage.RerankTopK)), 0.7)
|
||||
chatManage.RerankResult = final
|
||||
|
||||
// Log composite top scores and MMR selection summary
|
||||
topN := min(3, len(reranked))
|
||||
for i := 0; i < topN; i++ {
|
||||
pipelineInfo(ctx, "Rerank", "composite_top", map[string]interface{}{
|
||||
"rank": i + 1,
|
||||
"chunk_id": reranked[i].ID,
|
||||
"base_score": reranked[i].Metadata["base_score"],
|
||||
"final_score": fmt.Sprintf("%.4f", reranked[i].Score),
|
||||
})
|
||||
}
|
||||
|
||||
if len(chatManage.RerankResult) == 0 {
|
||||
pipelineWarn(ctx, "Rerank", "output", map[string]interface{}{
|
||||
"filtered_cnt": 0,
|
||||
})
|
||||
spanOutput = buildRerankSpanOutput(
|
||||
candidatesToRerank,
|
||||
passages,
|
||||
rawRerankResp,
|
||||
reranked,
|
||||
nil,
|
||||
chatManage,
|
||||
thresholdDegraded,
|
||||
)
|
||||
return ErrSearchNothing
|
||||
}
|
||||
|
||||
spanOutput = buildRerankSpanOutput(
|
||||
candidatesToRerank,
|
||||
passages,
|
||||
rawRerankResp,
|
||||
reranked,
|
||||
chatManage.RerankResult,
|
||||
chatManage,
|
||||
thresholdDegraded,
|
||||
)
|
||||
pipelineInfo(ctx, "Rerank", "output", map[string]interface{}{
|
||||
"filtered_cnt": len(chatManage.RerankResult),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
func buildRerankSpanOutput(
|
||||
candidates []*types.SearchResult,
|
||||
passages []string,
|
||||
modelScores []rerank.RankResult,
|
||||
composite []*types.SearchResult,
|
||||
final []*types.SearchResult,
|
||||
chatManage *types.ChatManage,
|
||||
thresholdDegraded bool,
|
||||
) map[string]interface{} {
|
||||
modelRows := make([]map[string]interface{}, 0, len(modelScores))
|
||||
for i, rr := range modelScores {
|
||||
row := map[string]interface{}{
|
||||
"rank": i + 1,
|
||||
"index": rr.Index,
|
||||
"model_score": rr.RelevanceScore,
|
||||
}
|
||||
if rr.Index >= 0 && rr.Index < len(candidates) {
|
||||
row["chunk_id"] = candidates[rr.Index].ID
|
||||
row["knowledge_id"] = candidates[rr.Index].KnowledgeID
|
||||
row["knowledge_title"] = candidates[rr.Index].KnowledgeTitle
|
||||
row["match_type"] = candidates[rr.Index].MatchType
|
||||
row["retrieval_score"] = candidates[rr.Index].Score
|
||||
if rr.Index < len(passages) {
|
||||
row["preview"] = langfuse.TruncateRunes(passages[rr.Index], 160)
|
||||
}
|
||||
}
|
||||
modelRows = append(modelRows, row)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"candidate_count": len(candidates),
|
||||
"model_result_count": len(modelScores),
|
||||
"composite_count": len(composite),
|
||||
"final_count": len(final),
|
||||
"threshold": chatManage.RerankThreshold,
|
||||
"rerank_top_k": chatManage.RerankTopK,
|
||||
"threshold_degraded": thresholdDegraded,
|
||||
"model_scores": langfuse.SummarizeRankScores(modelRows, 50),
|
||||
"composite_results": langfuse.SummarizeSearchResults(composite, 25),
|
||||
"final_results": langfuse.SummarizeSearchResults(final, 25),
|
||||
}
|
||||
if len(modelScores) > 50 {
|
||||
out["model_scores_truncated"] = len(modelScores) - 50
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rerank performs the actual reranking operation with given query and passages
|
||||
func (p *PluginRerank) rerank(ctx context.Context,
|
||||
chatManage *types.ChatManage, rerankModel rerank.Reranker, query string, passages []string,
|
||||
candidates []*types.SearchResult,
|
||||
) ([]rerank.RankResult, error) {
|
||||
pipelineInfo(ctx, "Rerank", "model_call", map[string]interface{}{
|
||||
"query_variant": query,
|
||||
"passages": len(passages),
|
||||
})
|
||||
|
||||
// Filter out empty or whitespace-only passages before sending to the API
|
||||
var cleanPassages []string
|
||||
var cleanCandidates []*types.SearchResult
|
||||
for i, p := range passages {
|
||||
if strings.TrimSpace(p) != "" {
|
||||
cleanPassages = append(cleanPassages, p)
|
||||
if i < len(candidates) {
|
||||
cleanCandidates = append(cleanCandidates, candidates[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cleanPassages) == 0 {
|
||||
pipelineInfo(ctx, "Rerank", "model_call_skip", map[string]interface{}{
|
||||
"reason": "all_passages_empty",
|
||||
})
|
||||
return nil, nil
|
||||
}
|
||||
passages = cleanPassages
|
||||
candidates = cleanCandidates
|
||||
|
||||
rerankResp, err := rerankModel.Rerank(ctx, query, passages)
|
||||
if err != nil {
|
||||
pipelineError(ctx, "Rerank", "model_call", map[string]interface{}{
|
||||
"query_variant": query,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log top scores for debugging
|
||||
pipelineInfo(ctx, "Rerank", "threshold", map[string]interface{}{
|
||||
"threshold": chatManage.RerankThreshold,
|
||||
})
|
||||
logged := min(5, len(rerankResp))
|
||||
for i := range logged {
|
||||
if rerankResp[i].Index < len(candidates) {
|
||||
pipelineInfo(ctx, "Rerank", "top_score", map[string]interface{}{
|
||||
"rank": i + 1,
|
||||
"score": rerankResp[i].RelevanceScore,
|
||||
"chunk_id": candidates[rerankResp[i].Index].ID,
|
||||
"match_type": candidates[rerankResp[i].Index].MatchType,
|
||||
"chunk_type": candidates[rerankResp[i].Index].ChunkType,
|
||||
"content_len": len(candidates[rerankResp[i].Index].Content),
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(rerankResp) > logged {
|
||||
pipelineInfo(ctx, "Rerank", "top_score_summary", map[string]interface{}{
|
||||
"total": len(rerankResp),
|
||||
"logged": logged,
|
||||
"truncated": len(rerankResp) - logged,
|
||||
})
|
||||
}
|
||||
|
||||
// Filter results based on threshold
|
||||
rankFilter := []rerank.RankResult{}
|
||||
for _, result := range rerankResp {
|
||||
if result.Index >= len(candidates) {
|
||||
continue
|
||||
}
|
||||
if result.RelevanceScore >= chatManage.RerankThreshold {
|
||||
rankFilter = append(rankFilter, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if threshold filtering removed all results but the top candidate
|
||||
// still has a reasonable score, keep it as a safety net. Skip fallback entirely
|
||||
// when the best score is too low — forcing irrelevant results is worse than
|
||||
// returning nothing and letting the caller handle the empty-result case.
|
||||
fallbackMinScore := rerankFallbackMinScore(chatManage.SearchTargets)
|
||||
if len(rankFilter) == 0 && len(rerankResp) > 0 && rerankResp[0].RelevanceScore >= fallbackMinScore {
|
||||
rankFilter = rerankResp[:1]
|
||||
pipelineInfo(ctx, "Rerank", "fallback_top1", map[string]interface{}{
|
||||
"reason": "all_below_threshold",
|
||||
"threshold": chatManage.RerankThreshold,
|
||||
"top_score": rerankResp[0].RelevanceScore,
|
||||
})
|
||||
} else if len(rankFilter) == 0 {
|
||||
pipelineInfo(ctx, "Rerank", "fallback_skip", map[string]interface{}{
|
||||
"reason": "top_score_too_low",
|
||||
"threshold": chatManage.RerankThreshold,
|
||||
"top_score": safeTopScore(rerankResp),
|
||||
})
|
||||
}
|
||||
|
||||
return rankFilter, nil
|
||||
}
|
||||
|
||||
func rerankFallbackMinScore(searchTargets types.SearchTargets) float64 {
|
||||
if searchTargets.HasRecallThresholdOverride() {
|
||||
// The user explicitly constrained this turn to a tag/document scope.
|
||||
// Preserve its best candidate instead of letting a global rerank
|
||||
// threshold erase the entire authoritative scope.
|
||||
return 0
|
||||
}
|
||||
return 0.15
|
||||
}
|
||||
|
||||
// ensureMetadata ensures the metadata is not nil
|
||||
func ensureMetadata(m map[string]string) map[string]string {
|
||||
if m == nil {
|
||||
return make(map[string]string)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func safeTopScore(results []rerank.RankResult) float64 {
|
||||
if len(results) == 0 {
|
||||
return 0
|
||||
}
|
||||
return results[0].RelevanceScore
|
||||
}
|
||||
|
||||
// compositeScore calculates the composite score for a search result
|
||||
func compositeScore(sr *types.SearchResult, modelScore, baseScore float64) float64 {
|
||||
sourceWeight := 1.0
|
||||
switch strings.ToLower(sr.KnowledgeSource) {
|
||||
case "web_search":
|
||||
sourceWeight = 0.95
|
||||
default:
|
||||
sourceWeight = 1.0
|
||||
}
|
||||
composite := 0.6*modelScore + 0.3*baseScore + 0.1*sourceWeight
|
||||
if composite < 0 {
|
||||
composite = 0
|
||||
}
|
||||
if composite > 1 {
|
||||
composite = 1
|
||||
}
|
||||
return composite
|
||||
}
|
||||
|
||||
// applyMMR applies the MMR algorithm to the search results with pre-computed token sets
|
||||
func applyMMR(
|
||||
ctx context.Context,
|
||||
results []*types.SearchResult,
|
||||
chatManage *types.ChatManage,
|
||||
k int,
|
||||
lambda float64,
|
||||
) []*types.SearchResult {
|
||||
if k <= 0 || len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
pipelineInfo(ctx, "Rerank", "mmr_start", map[string]interface{}{
|
||||
"lambda": lambda,
|
||||
"k": k,
|
||||
"candidates": len(results),
|
||||
})
|
||||
|
||||
// Pre-compute all token sets concurrently (CPU-bound tokenization)
|
||||
allTokenSets := ParallelMap(results, 0, func(i int, r *types.SearchResult) map[string]struct{} {
|
||||
return searchutil.TokenizeSimple(getEnrichedPassage(ctx, r))
|
||||
})
|
||||
|
||||
selected := make([]*types.SearchResult, 0, k)
|
||||
selectedTokenSets := make([]map[string]struct{}, 0, k)
|
||||
selectedIndices := make(map[int]struct{})
|
||||
|
||||
for len(selected) < k && len(selectedIndices) < len(results) {
|
||||
bestIdx := -1
|
||||
bestScore := -1.0
|
||||
|
||||
for i, r := range results {
|
||||
if _, isSelected := selectedIndices[i]; isSelected {
|
||||
continue
|
||||
}
|
||||
|
||||
relevance := r.Score
|
||||
redundancy := 0.0
|
||||
|
||||
// Use pre-computed token sets for redundancy calculation
|
||||
for _, selTokens := range selectedTokenSets {
|
||||
sim := searchutil.Jaccard(allTokenSets[i], selTokens)
|
||||
if sim > redundancy {
|
||||
redundancy = sim
|
||||
}
|
||||
}
|
||||
|
||||
mmr := lambda*relevance - (1.0-lambda)*redundancy
|
||||
if mmr > bestScore {
|
||||
bestScore = mmr
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
if bestIdx < 0 {
|
||||
break
|
||||
}
|
||||
|
||||
selected = append(selected, results[bestIdx])
|
||||
selectedTokenSets = append(selectedTokenSets, allTokenSets[bestIdx])
|
||||
selectedIndices[bestIdx] = struct{}{}
|
||||
}
|
||||
|
||||
// Compute average redundancy among selected using pre-computed token sets
|
||||
avgRed := 0.0
|
||||
if len(selected) > 1 {
|
||||
pairs := 0
|
||||
for i := 0; i < len(selectedTokenSets); i++ {
|
||||
for j := i + 1; j < len(selectedTokenSets); j++ {
|
||||
avgRed += searchutil.Jaccard(selectedTokenSets[i], selectedTokenSets[j])
|
||||
pairs++
|
||||
}
|
||||
}
|
||||
if pairs > 0 {
|
||||
avgRed /= float64(pairs)
|
||||
}
|
||||
}
|
||||
pipelineInfo(ctx, "Rerank", "mmr_done", map[string]interface{}{
|
||||
"selected": len(selected),
|
||||
"avg_redundancy": fmt.Sprintf("%.4f", avgRed),
|
||||
})
|
||||
return selected
|
||||
}
|
||||
|
||||
// --- Passage cleaning for rerank ---
|
||||
//
|
||||
// Rerank models work on semantic text similarity. Markdown formatting, raw URLs,
|
||||
// image references, table separators, and other structural syntax are noise that
|
||||
// can dilute the semantic signal. The functions below strip this noise before
|
||||
// passages are sent to the rerank model.
|
||||
|
||||
var (
|
||||
// reMarkdownImage matches  — the entire construct is noise.
|
||||
// URL group supports one level of balanced parentheses.
|
||||
reMarkdownImage = regexp.MustCompile(`!\[[^\]]*\]\([^()\s]*(?:\([^)]*\)[^()\s]*)*\)`)
|
||||
// reLinkedImage matches [](link_url) — unwrap to 
|
||||
// so that the subsequent reMarkdownImage pass can remove the image.
|
||||
reLinkedImage = regexp.MustCompile(
|
||||
`\[!\[([^\]]*)\]\(([^()\s]*(?:\([^)]*\)[^()\s]*)*)\)\]` +
|
||||
`\([^()\s]*(?:\([^)]*\)[^()\s]*)*\)`,
|
||||
)
|
||||
// reMarkdownLink matches [text](url) — we keep the text, drop the URL.
|
||||
// URL group supports one level of balanced parentheses.
|
||||
reMarkdownLink = regexp.MustCompile(`\[([^\]]+)\]\([^()\s]*(?:\([^)]*\)[^()\s]*)*\)`)
|
||||
// reRawURL matches standalone http(s) URLs.
|
||||
reRawURL = regexp.MustCompile(`https?://[^\s)\]>]+`)
|
||||
// reCodeBlock captures the semantic body of fenced code blocks.
|
||||
reCodeBlock = regexp.MustCompile("(?s)```[^\\r\\n]*\\r?\\n(.*?)\\r?\\n?```")
|
||||
// reLatexBlock captures the semantic body of block-level LaTeX ($$...$$).
|
||||
reLatexBlock = regexp.MustCompile(`(?s)\$\$(.*?)\$\$`)
|
||||
// reTableSep matches table separator rows like |---|---|.
|
||||
// Uses [ \t] instead of \s to avoid consuming newlines across rows.
|
||||
reTableSep = regexp.MustCompile(`(?m)^[ \t]*\|[ \t:|-]+\|[ \t]*$`)
|
||||
// reTableRow matches markdown table data rows like | col1 | col2 |.
|
||||
// Uses [ \t] instead of \s to avoid consuming newlines across rows.
|
||||
reTableRow = regexp.MustCompile(`(?m)^[ \t]*\|(.+?)\|[ \t]*$`)
|
||||
// reHeadingPrefix matches leading # markers in headings.
|
||||
reHeadingPrefix = regexp.MustCompile(`(?m)^#{1,6}\s+`)
|
||||
// reBlockquote matches leading > markers.
|
||||
reBlockquote = regexp.MustCompile(`(?m)^>\s?`)
|
||||
// reBoldItalic3 matches ***text*** wrappers (must come before 2 and 1).
|
||||
reBoldItalic3 = regexp.MustCompile(`\*{3}(.+?)\*{3}`)
|
||||
// reBoldItalic2 matches **text** wrappers.
|
||||
reBoldItalic2 = regexp.MustCompile(`\*{2}(.+?)\*{2}`)
|
||||
// reBoldItalic1 matches *text* wrappers.
|
||||
reBoldItalic1 = regexp.MustCompile(`\*(.+?)\*`)
|
||||
// reExcessiveNewlines collapses 3+ consecutive newlines into 2.
|
||||
reExcessiveNewlines = regexp.MustCompile(`\n{3,}`)
|
||||
// reListMarker matches unordered (- , * ) and ordered (1. ) list prefixes.
|
||||
reListMarker = regexp.MustCompile(`(?m)^[\t ]*(?:[-*+]|\d+\.)\s+`)
|
||||
// reHTMLTag matches HTML tags like <br>, <div class="...">, etc.
|
||||
reHTMLTag = regexp.MustCompile(`</?[a-zA-Z][^>]*>`)
|
||||
)
|
||||
|
||||
// cleanPassageForRerank strips markdown/structural noise from text to produce
|
||||
// a clean semantic passage for the rerank model. The cleaning is designed to
|
||||
// preserve all meaningful semantic content while removing formatting
|
||||
// that would confuse text-similarity scoring.
|
||||
func cleanPassageForRerank(text string) string {
|
||||
// 1. Unwrap code blocks so code-only candidates remain rerankable.
|
||||
text = reCodeBlock.ReplaceAllString(text, "$1")
|
||||
// 2. Unwrap LaTeX blocks so formula-only candidates remain rerankable.
|
||||
text = reLatexBlock.ReplaceAllString(text, "$1")
|
||||
// 3. Remove HTML tags
|
||||
text = reHTMLTag.ReplaceAllString(text, "")
|
||||
// 3.5. Unwrap nested [](link_url) → 
|
||||
// so that the next step removes the full construct cleanly.
|
||||
text = reLinkedImage.ReplaceAllString(text, "")
|
||||
// 4. Remove markdown image references entirely
|
||||
text = reMarkdownImage.ReplaceAllString(text, "")
|
||||
// 5. Convert markdown links to just their display text
|
||||
text = reMarkdownLink.ReplaceAllString(text, "$1")
|
||||
// 6. Remove standalone raw URLs
|
||||
text = reRawURL.ReplaceAllString(text, "")
|
||||
// 7. Remove table separator rows
|
||||
text = reTableSep.ReplaceAllString(text, "")
|
||||
// 7.5. Convert table data rows to plain text (strip | delimiters)
|
||||
text = reTableRow.ReplaceAllStringFunc(text, func(match string) string {
|
||||
inner := reTableRow.FindStringSubmatch(match)
|
||||
if len(inner) < 2 {
|
||||
return match
|
||||
}
|
||||
cells := strings.Split(inner[1], "|")
|
||||
var parts []string
|
||||
for _, cell := range cells {
|
||||
cell = strings.TrimSpace(cell)
|
||||
if cell != "" {
|
||||
parts = append(parts, cell)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
})
|
||||
// 8. Strip heading markers but keep heading text
|
||||
text = reHeadingPrefix.ReplaceAllString(text, "")
|
||||
// 9. Strip blockquote markers
|
||||
text = reBlockquote.ReplaceAllString(text, "")
|
||||
// 10. Unwrap bold/italic markers, keeping inner text (order: *** before ** before *)
|
||||
text = reBoldItalic3.ReplaceAllString(text, "$1")
|
||||
text = reBoldItalic2.ReplaceAllString(text, "$1")
|
||||
text = reBoldItalic1.ReplaceAllString(text, "$1")
|
||||
// 11. Strip list markers
|
||||
text = reListMarker.ReplaceAllString(text, "")
|
||||
// 12. Collapse excessive newlines
|
||||
text = reExcessiveNewlines.ReplaceAllString(text, "\n\n")
|
||||
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// getEnrichedPassage 合并Content、ImageInfo和GeneratedQuestions的文本内容
|
||||
func getEnrichedPassage(ctx context.Context, result *types.SearchResult) string {
|
||||
combinedText := cleanPassageForRerank(result.Content)
|
||||
var enrichments []string
|
||||
|
||||
// 解析ImageInfo
|
||||
if result.ImageInfo != "" {
|
||||
var imageInfos []types.ImageInfo
|
||||
err := json.Unmarshal([]byte(result.ImageInfo), &imageInfos)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Rerank", "image_info_parse", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
// 提取所有图片的描述和OCR文本
|
||||
for _, img := range imageInfos {
|
||||
if img.Caption != "" {
|
||||
enrichments = append(enrichments, img.Caption)
|
||||
}
|
||||
if img.OCRText != "" {
|
||||
enrichments = append(enrichments, img.OCRText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析ChunkMetadata中的GeneratedQuestions
|
||||
if len(result.ChunkMetadata) > 0 {
|
||||
var docMeta types.DocumentChunkMetadata
|
||||
err := json.Unmarshal(result.ChunkMetadata, &docMeta)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Rerank", "chunk_metadata_parse", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else if questionStrings := docMeta.GetQuestionStrings(); len(questionStrings) > 0 {
|
||||
enrichments = append(enrichments, strings.Join(questionStrings, "; "))
|
||||
}
|
||||
}
|
||||
|
||||
if len(enrichments) == 0 {
|
||||
return combinedText
|
||||
}
|
||||
|
||||
// 组合内容和增强信息
|
||||
if combinedText != "" {
|
||||
combinedText += "\n\n"
|
||||
}
|
||||
combinedText += strings.Join(enrichments, "\n")
|
||||
|
||||
return combinedText
|
||||
}
|
||||
|
||||
func logRerankInputScoreSample(ctx context.Context, results []*types.SearchResult) {
|
||||
const maxLogRows = 8
|
||||
limit := min(maxLogRows, len(results))
|
||||
for i := 0; i < limit; i++ {
|
||||
sr := results[i]
|
||||
pipelineInfo(ctx, "Rerank", "input_score", map[string]interface{}{
|
||||
"index": i,
|
||||
"chunk_id": sr.ID,
|
||||
"score": fmt.Sprintf("%.4f", sr.Score),
|
||||
"match_type": sr.MatchType,
|
||||
})
|
||||
}
|
||||
if len(results) > limit {
|
||||
pipelineInfo(ctx, "Rerank", "input_score_summary", map[string]interface{}{
|
||||
"total": len(results),
|
||||
"logged": limit,
|
||||
"truncated": len(results) - limit,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestGetEnrichedPassageKeepsQuestionsFromEarlierRevision(t *testing.T) {
|
||||
metadata, err := json.Marshal(types.DocumentChunkMetadata{
|
||||
GeneratedQuestionsRevision: 1,
|
||||
GeneratedQuestions: []types.GeneratedQuestion{{
|
||||
ID: "old", Question: "question generated before the edit",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
passage := getEnrichedPassage(context.Background(), &types.SearchResult{
|
||||
Content: "edited chunk body",
|
||||
ChunkMetadata: types.JSON(metadata),
|
||||
})
|
||||
if !strings.Contains(passage, "question generated before the edit") {
|
||||
t.Fatalf("earlier generated question was excluded from rerank passage: %q", passage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnrichedPassageKeepsCodeAndMathCandidates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "code only",
|
||||
content: "```go\nfunc answer() int { return 42 }\n```",
|
||||
want: "func answer() int { return 42 }",
|
||||
},
|
||||
{
|
||||
name: "math only",
|
||||
content: "$$\nE = mc^2\n$$",
|
||||
want: "E = mc^2",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
passage := getEnrichedPassage(context.Background(), &types.SearchResult{Content: tt.content})
|
||||
if strings.TrimSpace(passage) == "" {
|
||||
t.Fatal("semantic-only candidate was removed from the rerank passage")
|
||||
}
|
||||
if !strings.Contains(passage, tt.want) {
|
||||
t.Fatalf("rerank passage %q does not preserve %q", passage, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanPassageForRerank(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
name: "plain text unchanged",
|
||||
input: "这是一段普通的文本内容",
|
||||
expect: "这是一段普通的文本内容",
|
||||
},
|
||||
{
|
||||
name: "remove markdown images",
|
||||
input: "前文  后文",
|
||||
expect: "前文 后文",
|
||||
},
|
||||
{
|
||||
name: "convert markdown links to text",
|
||||
input: "请参考 [官方文档](https://docs.example.com) 了解详情",
|
||||
expect: "请参考 官方文档 了解详情",
|
||||
},
|
||||
{
|
||||
name: "remove standalone URLs",
|
||||
input: "访问 https://example.com/path?q=1&b=2 获取更多信息",
|
||||
expect: "访问 获取更多信息",
|
||||
},
|
||||
{
|
||||
name: "unwrap code blocks",
|
||||
input: "示例代码:\n```python\nprint('hello')\n```\n以上是示例",
|
||||
expect: "示例代码:\nprint('hello')\n以上是示例",
|
||||
},
|
||||
{
|
||||
name: "unwrap LaTeX blocks",
|
||||
input: "公式如下 $$E=mc^2$$ 其中E是能量",
|
||||
expect: "公式如下 E=mc^2 其中E是能量",
|
||||
},
|
||||
{
|
||||
name: "remove table separator rows and convert data rows",
|
||||
input: "| 名称 | 值 |\n| --- | --- |\n| A | 1 |",
|
||||
expect: "名称, 值\n\nA, 1",
|
||||
},
|
||||
{
|
||||
name: "strip heading markers",
|
||||
input: "## 第二章 概述\n### 2.1 背景",
|
||||
expect: "第二章 概述\n2.1 背景",
|
||||
},
|
||||
{
|
||||
name: "strip blockquote markers",
|
||||
input: "> 这是一段引用\n> 第二行引用",
|
||||
expect: "这是一段引用\n第二行引用",
|
||||
},
|
||||
{
|
||||
name: "unwrap bold and italic",
|
||||
input: "这是 **加粗** 和 *斜体* 以及 ***粗斜体*** 文本",
|
||||
expect: "这是 加粗 和 斜体 以及 粗斜体 文本",
|
||||
},
|
||||
{
|
||||
name: "strip list markers",
|
||||
input: "- 项目一\n- 项目二\n1. 有序一\n2. 有序二",
|
||||
expect: "项目一\n项目二\n有序一\n有序二",
|
||||
},
|
||||
{
|
||||
name: "remove HTML tags",
|
||||
input: "文本<br>换行<div class=\"test\">内容</div>结尾",
|
||||
expect: "文本换行内容结尾",
|
||||
},
|
||||
{
|
||||
name: "collapse excessive newlines",
|
||||
input: "段落一\n\n\n\n\n段落二",
|
||||
expect: "段落一\n\n段落二",
|
||||
},
|
||||
{
|
||||
name: "combined real-world passage",
|
||||
input: `## 产品介绍
|
||||
|
||||
这是一个 **重要的** 产品。详见 [产品页面](https://example.com/product)。
|
||||
|
||||

|
||||
|
||||
> 用户评价:非常好用
|
||||
|
||||
- 功能一
|
||||
- 功能二
|
||||
|
||||
` + "```json\n{\"key\": \"value\"}\n```",
|
||||
expect: "产品介绍\n\n这是一个 重要的 产品。详见 产品页面。\n\n用户评价:非常好用\n\n功能一\n功能二\n\n{\"key\": \"value\"}",
|
||||
},
|
||||
{
|
||||
name: "convert table data rows to plain text",
|
||||
input: "| col1 | col2 | col3 |",
|
||||
expect: "col1, col2, col3",
|
||||
},
|
||||
{
|
||||
name: "multi-row table fully converted",
|
||||
input: "| Header1 | Header2 |\n| --- | --- |\n| data1 | data2 |\n| data3 | data4 |",
|
||||
expect: "Header1, Header2\n\ndata1, data2\ndata3, data4",
|
||||
},
|
||||
{
|
||||
name: "table-only passage becomes empty after separator removal",
|
||||
input: "| --- | --- |",
|
||||
expect: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only after cleaning",
|
||||
input: " \n\n ",
|
||||
expect: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := cleanPassageForRerank(tt.input)
|
||||
if got != tt.expect {
|
||||
t.Errorf("cleanPassageForRerank():\ngot: %q\nexpect: %q", got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestRerankFallbackMinScoreForExplicitScope(t *testing.T) {
|
||||
if got := rerankFallbackMinScore(nil); got != 0.15 {
|
||||
t.Fatalf("default fallback minimum = %v, want 0.15", got)
|
||||
}
|
||||
|
||||
targets := types.SearchTargets{{DisableRecallThresholds: true}}
|
||||
if got := rerankFallbackMinScore(targets); got != 0 {
|
||||
t.Fatalf("explicit-scope fallback minimum = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginSearch implements search functionality for chat pipeline
|
||||
type PluginSearch struct {
|
||||
knowledgeBaseService interfaces.KnowledgeBaseService
|
||||
knowledgeService interfaces.KnowledgeService
|
||||
chunkService interfaces.ChunkService
|
||||
config *config.Config
|
||||
webSearchService interfaces.WebSearchService
|
||||
tenantService interfaces.TenantService
|
||||
sessionService interfaces.SessionService
|
||||
webSearchStateService interfaces.WebSearchStateService
|
||||
webSearchProviderRepo interfaces.WebSearchProviderRepository
|
||||
}
|
||||
|
||||
func NewPluginSearch(eventManager *EventManager,
|
||||
knowledgeBaseService interfaces.KnowledgeBaseService,
|
||||
knowledgeService interfaces.KnowledgeService,
|
||||
chunkService interfaces.ChunkService,
|
||||
config *config.Config,
|
||||
webSearchService interfaces.WebSearchService,
|
||||
tenantService interfaces.TenantService,
|
||||
sessionService interfaces.SessionService,
|
||||
webSearchStateService interfaces.WebSearchStateService,
|
||||
webSearchProviderRepo interfaces.WebSearchProviderRepository,
|
||||
) *PluginSearch {
|
||||
res := &PluginSearch{
|
||||
knowledgeBaseService: knowledgeBaseService,
|
||||
knowledgeService: knowledgeService,
|
||||
chunkService: chunkService,
|
||||
config: config,
|
||||
webSearchService: webSearchService,
|
||||
tenantService: tenantService,
|
||||
sessionService: sessionService,
|
||||
webSearchStateService: webSearchStateService,
|
||||
webSearchProviderRepo: webSearchProviderRepo,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginSearch) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHUNK_SEARCH}
|
||||
}
|
||||
|
||||
// OnEvent handles search events in the chat pipeline
|
||||
func (p *PluginSearch) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
// Check if we have search targets or web search enabled
|
||||
hasKBTargets := types.HasKnowledgeRetrievalScope(
|
||||
chatManage.SearchTargets,
|
||||
chatManage.KnowledgeBaseIDs,
|
||||
chatManage.KnowledgeIDs,
|
||||
)
|
||||
if !hasKBTargets && !chatManage.WebSearchEnabled {
|
||||
pipelineError(ctx, "Search", "kb_not_found", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Search", "input", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"rewrite_query": chatManage.RewriteQuery,
|
||||
"search_targets": len(chatManage.SearchTargets),
|
||||
"tenant_id": chatManage.TenantID,
|
||||
"web_enabled": chatManage.WebSearchEnabled,
|
||||
})
|
||||
|
||||
// Run KB search and web search concurrently
|
||||
pipelineInfo(ctx, "Search", "plan", map[string]interface{}{
|
||||
"search_targets": len(chatManage.SearchTargets),
|
||||
"embedding_top_k": chatManage.EmbeddingTopK,
|
||||
"vector_threshold": chatManage.VectorThreshold,
|
||||
"keyword_threshold": chatManage.KeywordThreshold,
|
||||
})
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
allResults := make([]*types.SearchResult, 0)
|
||||
var kbSearchErr error
|
||||
|
||||
wg.Add(2)
|
||||
// Goroutine 1: Knowledge base search using SearchTargets
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
kbResults, err := p.searchByTargets(ctx, chatManage)
|
||||
kbSearchErr = err
|
||||
if len(kbResults) > 0 {
|
||||
mu.Lock()
|
||||
allResults = append(allResults, kbResults...)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Goroutine 2: Web search (if enabled)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
webResults := p.searchWebIfEnabled(ctx, chatManage)
|
||||
if len(webResults) > 0 {
|
||||
mu.Lock()
|
||||
allResults = append(allResults, webResults...)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
if kbSearchErr != nil && len(allResults) == 0 {
|
||||
pipelineError(ctx, "Search", "kb_search_failed", map[string]interface{}{
|
||||
"error": kbSearchErr.Error(),
|
||||
})
|
||||
return ErrSearch.WithError(kbSearchErr)
|
||||
}
|
||||
if kbSearchErr != nil {
|
||||
pipelineWarn(ctx, "Search", "kb_search_partial_failure", map[string]interface{}{
|
||||
"error": kbSearchErr.Error(),
|
||||
"result_count": len(allResults),
|
||||
})
|
||||
}
|
||||
|
||||
chatManage.SearchResult = allResults
|
||||
|
||||
logSearchScoreSample(ctx, "result_score_before_normalize", chatManage.SearchResult)
|
||||
|
||||
// If recall is low, attempt query expansion with keyword-focused search
|
||||
if chatManage.EnableQueryExpansion && len(chatManage.SearchResult) < max(1, chatManage.EmbeddingTopK) {
|
||||
expResults := p.runQueryExpansion(ctx, chatManage)
|
||||
if len(expResults) > 0 {
|
||||
chatManage.SearchResult = append(chatManage.SearchResult, expResults...)
|
||||
}
|
||||
}
|
||||
|
||||
logSearchScoreSample(ctx, "final_score", chatManage.SearchResult)
|
||||
|
||||
// Return if we have results
|
||||
if len(chatManage.SearchResult) != 0 {
|
||||
pipelineInfo(ctx, "Search", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"result_count": len(chatManage.SearchResult),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
pipelineWarn(ctx, "Search", "output", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"result_count": 0,
|
||||
})
|
||||
return ErrSearchNothing
|
||||
}
|
||||
|
||||
// getSearchResultFromHistory retrieves relevant knowledge references from chat history
|
||||
func getSearchResultFromHistory(chatManage *types.ChatManage) []*types.SearchResult {
|
||||
if len(chatManage.History) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Search history in reverse chronological order
|
||||
for i := len(chatManage.History) - 1; i >= 0; i-- {
|
||||
if len(chatManage.History[i].KnowledgeReferences) > 0 {
|
||||
// Mark all references as history matches
|
||||
for _, reference := range chatManage.History[i].KnowledgeReferences {
|
||||
reference.MatchType = types.MatchTypeHistory
|
||||
}
|
||||
return chatManage.History[i].KnowledgeReferences
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeDuplicateResults(results []*types.SearchResult) []*types.SearchResult {
|
||||
seen := make(map[string]bool)
|
||||
contentSig := make(map[string]string) // sig -> first chunk ID
|
||||
var uniqueResults []*types.SearchResult
|
||||
for _, r := range results {
|
||||
// Only deduplicate by exact chunk ID — do NOT treat shared ParentChunkID
|
||||
// as duplicates, because different child chunks of the same parent carry
|
||||
// different content segments that may all be relevant.
|
||||
if seen[r.ID] {
|
||||
logger.Debugf(context.Background(), "Dedup: chunk %s removed due to duplicate ID", r.ID)
|
||||
continue
|
||||
}
|
||||
sig := buildContentSignature(r.Content)
|
||||
if sig != "" {
|
||||
if firstChunk, exists := contentSig[sig]; exists {
|
||||
logger.Debugf(context.Background(), "Dedup: chunk %s removed due to content signature (dup of %s, sig prefix: %.50s...)", r.ID, firstChunk, sig)
|
||||
continue
|
||||
}
|
||||
contentSig[sig] = r.ID
|
||||
}
|
||||
seen[r.ID] = true
|
||||
uniqueResults = append(uniqueResults, r)
|
||||
}
|
||||
return uniqueResults
|
||||
}
|
||||
|
||||
func buildContentSignature(content string) string {
|
||||
return searchutil.BuildContentSignature(content)
|
||||
}
|
||||
|
||||
// removePartialOverlaps drops chunks whose content is largely contained within
|
||||
// a higher-scored chunk, even across different knowledge sources. This catches
|
||||
// cross-KB duplicates and near-duplicates that exact-signature dedup misses.
|
||||
//
|
||||
// Two thresholds are used:
|
||||
// - Substring containment: if the normalized short text is a literal substring
|
||||
// of the normalized long text, the shorter chunk is removed.
|
||||
// - Token overlap coefficient >= 0.85: if 85%+ of the smaller chunk's tokens
|
||||
// appear in the larger chunk, the smaller one is redundant.
|
||||
//
|
||||
// The input slice MUST already be deduplicated by ID/signature. Within each
|
||||
// pair the chunk with the lower score is the candidate for removal; ties are
|
||||
// broken by content length (longer wins).
|
||||
func removePartialOverlaps(ctx context.Context, results []*types.SearchResult) []*types.SearchResult {
|
||||
const overlapThreshold = 0.85
|
||||
|
||||
if len(results) <= 1 {
|
||||
return results
|
||||
}
|
||||
|
||||
type normEntry struct {
|
||||
norm string
|
||||
result *types.SearchResult
|
||||
}
|
||||
|
||||
entries := make([]normEntry, 0, len(results))
|
||||
for _, r := range results {
|
||||
entries = append(entries, normEntry{
|
||||
norm: searchutil.NormalizeContent(r.Content),
|
||||
result: r,
|
||||
})
|
||||
}
|
||||
|
||||
removed := make(map[int]bool)
|
||||
|
||||
for i := 0; i < len(entries); i++ {
|
||||
if removed[i] {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(entries); j++ {
|
||||
if removed[j] {
|
||||
continue
|
||||
}
|
||||
|
||||
a, b := entries[i], entries[j]
|
||||
|
||||
shortIdx, longIdx := i, j
|
||||
if len(a.norm) > len(b.norm) {
|
||||
shortIdx, longIdx = j, i
|
||||
}
|
||||
|
||||
contained := searchutil.IsContentContained(
|
||||
entries[shortIdx].norm, entries[longIdx].norm,
|
||||
)
|
||||
|
||||
if !contained {
|
||||
ratio := searchutil.ContentOverlapRatio(
|
||||
entries[shortIdx].result.Content,
|
||||
entries[longIdx].result.Content,
|
||||
)
|
||||
if ratio < overlapThreshold {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
victim := shortIdx
|
||||
if entries[shortIdx].result.Score > entries[longIdx].result.Score {
|
||||
victim = longIdx
|
||||
}
|
||||
removed[victim] = true
|
||||
|
||||
keptIdx := i
|
||||
if victim == i {
|
||||
keptIdx = j
|
||||
}
|
||||
pipelineInfo(ctx, "Merge", "partial_overlap_drop", map[string]interface{}{
|
||||
"kept_id": entries[keptIdx].result.ID,
|
||||
"dropped_id": entries[victim].result.ID,
|
||||
"contained": contained,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]*types.SearchResult, 0, len(results)-len(removed))
|
||||
for i, e := range entries {
|
||||
if !removed[i] {
|
||||
out = append(out, e.result)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func logSearchScoreSample(ctx context.Context, action string, results []*types.SearchResult) {
|
||||
const maxLogRows = 8
|
||||
limit := min(maxLogRows, len(results))
|
||||
for i := 0; i < limit; i++ {
|
||||
r := results[i]
|
||||
pipelineInfo(ctx, "Search", action, map[string]interface{}{
|
||||
"index": i,
|
||||
"chunk_id": r.ID,
|
||||
"score": fmt.Sprintf("%.4f", r.Score),
|
||||
"match_type": r.MatchType,
|
||||
})
|
||||
}
|
||||
if len(results) > limit {
|
||||
pipelineInfo(ctx, "Search", action+"_summary", map[string]interface{}{
|
||||
"total": len(results),
|
||||
"logged": limit,
|
||||
"truncated": len(results) - limit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// targetReportsEmbedFailure reports whether an embedding failure for a KB should
|
||||
// be recorded as a retrieval error. Wiki/graph-only KBs have no vector or keyword
|
||||
// index to degrade into; HybridSearch returns empty without error. When KB metadata
|
||||
// is unavailable, callers still attempt keyword-degraded search.
|
||||
func targetReportsEmbedFailure(kb *types.KnowledgeBase) bool {
|
||||
if kb == nil {
|
||||
return false
|
||||
}
|
||||
if kb.Type == types.KnowledgeBaseTypeFAQ {
|
||||
return true
|
||||
}
|
||||
if kb.IsKeywordEnabled() {
|
||||
return false
|
||||
}
|
||||
return kb.IsVectorEnabled()
|
||||
}
|
||||
|
||||
// searchByTargets performs KB searches using pre-computed SearchTargets.
|
||||
// Targets sharing the same underlying embedding model (identified by model
|
||||
// name + endpoint, not just model ID) are grouped so the query embedding is
|
||||
// computed once per model AND all full-KB targets in a group are combined into
|
||||
// a single retrieval call, reducing both embedding API calls and DB round-trips.
|
||||
func (p *PluginSearch) searchByTargets(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
) ([]*types.SearchResult, error) {
|
||||
if len(chatManage.SearchTargets) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
queryText := strings.TrimSpace(chatManage.RewriteQuery)
|
||||
|
||||
// Batch-fetch KB records to determine embedding model grouping.
|
||||
// On failure, all targets fall into an empty-key group and HybridSearch
|
||||
// computes the embedding per-KB (graceful degradation).
|
||||
kbIDs := make([]string, 0, len(chatManage.SearchTargets))
|
||||
for _, t := range chatManage.SearchTargets {
|
||||
kbIDs = append(kbIDs, t.KnowledgeBaseID)
|
||||
}
|
||||
var kbList []*types.KnowledgeBase
|
||||
kbMap := make(map[string]*types.KnowledgeBase)
|
||||
if kbs, err := p.knowledgeBaseService.GetKnowledgeBasesByIDsOnly(ctx, kbIDs); err == nil {
|
||||
kbList = kbs
|
||||
for _, kb := range kbs {
|
||||
if kb != nil {
|
||||
kbMap[kb.ID] = kb
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pipelineWarn(ctx, "Search", "batch_kb_fetch_error", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve actual model identities (name + endpoint) so that cross-tenant
|
||||
// KBs backed by the same physical model share one embedding computation.
|
||||
modelKeyMap := p.knowledgeBaseService.ResolveEmbeddingModelKeys(ctx, kbList)
|
||||
|
||||
groups := make(map[string][]*types.SearchTarget)
|
||||
for _, t := range chatManage.SearchTargets {
|
||||
key := modelKeyMap[t.KnowledgeBaseID] // empty string if unresolved
|
||||
groups[key] = append(groups[key], t)
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Search", "embedding_groups", map[string]interface{}{
|
||||
"total_targets": len(chatManage.SearchTargets),
|
||||
"unique_models": len(groups),
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var results []*types.SearchResult
|
||||
var firstErr error
|
||||
var errOnce sync.Once
|
||||
recordError := func(err error) {
|
||||
if err != nil {
|
||||
errOnce.Do(func() { firstErr = err })
|
||||
}
|
||||
}
|
||||
|
||||
for modelKey, targets := range groups {
|
||||
wg.Add(1)
|
||||
go func(modelKey string, targets []*types.SearchTarget) {
|
||||
defer wg.Done()
|
||||
|
||||
// Compute embedding once for this model group. When that fails, retain
|
||||
// only targets that have a real keyword index; vector-only targets must
|
||||
// propagate the embedding failure instead of looking like empty recall.
|
||||
var queryEmbedding []float32
|
||||
disableVector := false
|
||||
searchableTargets := targets
|
||||
if modelKey != "" {
|
||||
emb, err := p.knowledgeBaseService.GetQueryEmbedding(ctx, targets[0].KnowledgeBaseID, queryText)
|
||||
if err != nil {
|
||||
searchableTargets = make([]*types.SearchTarget, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
kb := kbMap[target.KnowledgeBaseID]
|
||||
if !targetReportsEmbedFailure(kb) {
|
||||
searchableTargets = append(searchableTargets, target)
|
||||
continue
|
||||
}
|
||||
recordError(fmt.Errorf("knowledge base %s has no keyword fallback: %w", target.KnowledgeBaseID, err))
|
||||
}
|
||||
pipelineWarn(ctx, "Search", "group_embed_degrade_keyword", map[string]interface{}{
|
||||
"model_key": modelKey,
|
||||
"kb_id": targets[0].KnowledgeBaseID,
|
||||
"error": err.Error(),
|
||||
"fallback_targets": len(searchableTargets),
|
||||
"failed_targets": len(targets) - len(searchableTargets),
|
||||
})
|
||||
disableVector = true
|
||||
} else {
|
||||
queryEmbedding = emb
|
||||
}
|
||||
}
|
||||
|
||||
// Separate full-KB targets (can be combined into one retrieval)
|
||||
// from specific-knowledge targets (need per-target direct loading).
|
||||
var fullKBIDs []string
|
||||
var knowledgeTargets []*types.SearchTarget
|
||||
for _, t := range searchableTargets {
|
||||
if t.Type == types.SearchTargetTypeKnowledgeBase && len(t.TagIDs) == 0 {
|
||||
fullKBIDs = append(fullKBIDs, t.KnowledgeBaseID)
|
||||
} else {
|
||||
knowledgeTargets = append(knowledgeTargets, t)
|
||||
}
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Search", "group_plan", map[string]interface{}{
|
||||
"model_key": modelKey,
|
||||
"combined_kb_count": len(fullKBIDs),
|
||||
"individual_targets": len(knowledgeTargets),
|
||||
"vector_len": len(queryEmbedding),
|
||||
})
|
||||
|
||||
var innerWg sync.WaitGroup
|
||||
|
||||
// Combined search: one HybridSearch call spanning all full-KB targets
|
||||
if len(fullKBIDs) > 0 {
|
||||
innerWg.Add(1)
|
||||
go func() {
|
||||
defer innerWg.Done()
|
||||
|
||||
params := types.SearchParams{
|
||||
QueryText: queryText,
|
||||
QueryEmbedding: queryEmbedding,
|
||||
KnowledgeBaseIDs: fullKBIDs,
|
||||
VectorThreshold: chatManage.VectorThreshold,
|
||||
KeywordThreshold: chatManage.KeywordThreshold,
|
||||
MatchCount: chatManage.EmbeddingTopK,
|
||||
SkipContextEnrichment: true,
|
||||
DisableVectorMatch: disableVector,
|
||||
}
|
||||
res, err := p.knowledgeBaseService.HybridSearch(ctx, fullKBIDs[0], params)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Search", "combined_kb_search_error", map[string]interface{}{
|
||||
"kb_ids": fullKBIDs,
|
||||
"error": err.Error(),
|
||||
})
|
||||
recordError(err)
|
||||
return
|
||||
}
|
||||
pipelineInfo(ctx, "Search", "combined_kb_result", map[string]interface{}{
|
||||
"kb_ids": fullKBIDs,
|
||||
"hit_count": len(res),
|
||||
})
|
||||
mu.Lock()
|
||||
results = append(results, res...)
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
// Individual search: per-target handling for specific-knowledge targets
|
||||
for _, target := range knowledgeTargets {
|
||||
innerWg.Add(1)
|
||||
go func(t *types.SearchTarget) {
|
||||
defer innerWg.Done()
|
||||
recordError(p.searchSingleTarget(
|
||||
ctx, chatManage, t, queryText, queryEmbedding, disableVector, &mu, &results,
|
||||
))
|
||||
}(target)
|
||||
}
|
||||
|
||||
innerWg.Wait()
|
||||
}(modelKey, targets)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
pipelineInfo(ctx, "Search", "kb_result_summary", map[string]interface{}{
|
||||
"total_hits": len(results),
|
||||
})
|
||||
return results, firstErr
|
||||
}
|
||||
|
||||
// searchSingleTarget performs hybrid retrieval inside one constrained target.
|
||||
func (p *PluginSearch) searchSingleTarget(
|
||||
ctx context.Context,
|
||||
chatManage *types.ChatManage,
|
||||
t *types.SearchTarget,
|
||||
queryText string,
|
||||
queryEmbedding []float32,
|
||||
disableVector bool,
|
||||
mu *sync.Mutex,
|
||||
results *[]*types.SearchResult,
|
||||
) error {
|
||||
if t.Type == types.SearchTargetTypeKnowledge && len(t.KnowledgeIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
vectorThreshold, keywordThreshold := t.RecallThresholds(
|
||||
chatManage.VectorThreshold,
|
||||
chatManage.KeywordThreshold,
|
||||
)
|
||||
if t.DisableRecallThresholds {
|
||||
pipelineInfo(ctx, "Search", "explicit_scope_threshold_override", map[string]interface{}{
|
||||
"kb_id": t.KnowledgeBaseID,
|
||||
"knowledge_id_count": len(t.KnowledgeIDs),
|
||||
"tag_id_count": len(t.TagIDs),
|
||||
})
|
||||
}
|
||||
params := types.SearchParams{
|
||||
QueryText: queryText,
|
||||
QueryEmbedding: queryEmbedding,
|
||||
VectorThreshold: vectorThreshold,
|
||||
KeywordThreshold: keywordThreshold,
|
||||
MatchCount: chatManage.EmbeddingTopK,
|
||||
TagIDs: t.TagIDs,
|
||||
ScopeTagIDs: t.ScopeTagIDs,
|
||||
SkipContextEnrichment: true,
|
||||
DisableVectorMatch: disableVector,
|
||||
}
|
||||
if t.Type == types.SearchTargetTypeKnowledge {
|
||||
params.KnowledgeIDs = t.KnowledgeIDs
|
||||
}
|
||||
res, err := p.knowledgeBaseService.HybridSearch(ctx, t.KnowledgeBaseID, params)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Search", "kb_search_error", map[string]interface{}{
|
||||
"kb_id": t.KnowledgeBaseID,
|
||||
"target_type": t.Type,
|
||||
"query": params.QueryText,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
pipelineInfo(ctx, "Search", "kb_result", map[string]interface{}{
|
||||
"kb_id": t.KnowledgeBaseID,
|
||||
"target_type": t.Type,
|
||||
"hit_count": len(res),
|
||||
})
|
||||
mu.Lock()
|
||||
*results = append(*results, res...)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchWebIfEnabled executes web search when enabled and returns converted results
|
||||
func (p *PluginSearch) searchWebIfEnabled(ctx context.Context, chatManage *types.ChatManage) []*types.SearchResult {
|
||||
if !chatManage.WebSearchEnabled || p.webSearchService == nil || p.tenantService == nil {
|
||||
return nil
|
||||
}
|
||||
tenant, _ := types.TenantInfoFromContext(ctx)
|
||||
providerID := chatManage.WebSearchProviderID
|
||||
|
||||
if providerID == "" {
|
||||
pipelineWarn(ctx, "Search", "web_config_missing", map[string]interface{}{
|
||||
"tenant_id": chatManage.TenantID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
webConfig := types.EffectiveWebSearchConfig(nil)
|
||||
if tenant != nil {
|
||||
webConfig = types.EffectiveWebSearchConfig(tenant.WebSearchConfig)
|
||||
}
|
||||
|
||||
// Apply agent-level web search overrides
|
||||
if chatManage.WebSearchMaxResults > 0 {
|
||||
webConfig.MaxResults = chatManage.WebSearchMaxResults
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "Search", "web_request", map[string]interface{}{
|
||||
"tenant_id": chatManage.TenantID,
|
||||
"provider_id": providerID,
|
||||
})
|
||||
webCtx, webSpan := langfuse.GetManager().StartSpan(ctx, langfuse.SpanOptions{
|
||||
Name: "web_search",
|
||||
Input: map[string]interface{}{
|
||||
"provider_id": providerID,
|
||||
"query": chatManage.RewriteQuery,
|
||||
"max_results": webConfig.MaxResults,
|
||||
},
|
||||
})
|
||||
webResults, err := p.webSearchService.Search(webCtx, providerID, webConfig, chatManage.RewriteQuery)
|
||||
webSpan.Finish(map[string]interface{}{
|
||||
"hit_count": len(webResults),
|
||||
}, nil, err)
|
||||
if err != nil {
|
||||
pipelineWarn(ctx, "Search", "web_search_error", map[string]interface{}{
|
||||
"tenant_id": chatManage.TenantID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// Build questions using RewriteQuery only
|
||||
// questions := []string{strings.TrimSpace(chatManage.RewriteQuery)}
|
||||
// Load session-scoped temp KB state from Redis using WebSearchStateRepository
|
||||
// tempKBID, seen, ids := p.webSearchStateService.GetWebSearchTempKBState(ctx, chatManage.SessionID)
|
||||
// compressed, kbID, newSeen, newIDs, err := p.webSearchService.CompressWithRAG(
|
||||
// ctx, chatManage.SessionID, tempKBID, questions, webResults, webConfig,
|
||||
// p.knowledgeBaseService, p.knowledgeService, seen, ids,
|
||||
// )
|
||||
// if err != nil {
|
||||
// pipelineWarn(ctx, "Search", "web_compress_error", map[string]interface{}{
|
||||
// "error": err.Error(),
|
||||
// })
|
||||
// } else {
|
||||
// webResults = compressed
|
||||
// // Persist temp KB state back into Redis using WebSearchStateRepository
|
||||
// p.webSearchStateService.SaveWebSearchTempKBState(ctx, chatManage.SessionID, kbID, newSeen, newIDs)
|
||||
// }
|
||||
res := searchutil.ConvertWebSearchResults(webResults)
|
||||
pipelineInfo(ctx, "Search", "web_hits", map[string]interface{}{
|
||||
"hit_count": len(res),
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/searchutil"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginSearch implements search functionality for chat pipeline
|
||||
type PluginSearchEntity struct {
|
||||
graphRepo interfaces.RetrieveGraphRepository
|
||||
chunkRepo interfaces.ChunkRepository
|
||||
knowledgeRepo interfaces.KnowledgeRepository
|
||||
}
|
||||
|
||||
// NewPluginSearchEntity creates a new plugin search entity
|
||||
func NewPluginSearchEntity(
|
||||
eventManager *EventManager,
|
||||
graphRepository interfaces.RetrieveGraphRepository,
|
||||
chunkRepository interfaces.ChunkRepository,
|
||||
knowledgeRepository interfaces.KnowledgeRepository,
|
||||
) *PluginSearchEntity {
|
||||
res := &PluginSearchEntity{
|
||||
graphRepo: graphRepository,
|
||||
chunkRepo: chunkRepository,
|
||||
knowledgeRepo: knowledgeRepository,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the list of event types this plugin responds to
|
||||
func (p *PluginSearchEntity) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.ENTITY_SEARCH}
|
||||
}
|
||||
|
||||
// OnEvent processes triggered events
|
||||
func (p *PluginSearchEntity) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
entity := chatManage.Entity
|
||||
if len(entity) == 0 {
|
||||
logger.Infof(ctx, "No entity found")
|
||||
return next()
|
||||
}
|
||||
|
||||
// Use EntityKBIDs (knowledge bases with ExtractConfig enabled)
|
||||
knowledgeBaseIDs := chatManage.EntityKBIDs
|
||||
// Use EntityKnowledge (KnowledgeID -> KnowledgeBaseID mapping for graph-enabled files)
|
||||
entityKnowledge := chatManage.EntityKnowledge
|
||||
|
||||
if len(knowledgeBaseIDs) == 0 && len(entityKnowledge) == 0 {
|
||||
logger.Warnf(ctx, "No knowledge base IDs or knowledge IDs with ExtractConfig enabled for entity search")
|
||||
return next()
|
||||
}
|
||||
|
||||
// Parallel search across multiple knowledge bases and individual files
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var allNodes []*types.GraphNode
|
||||
var allRelations []*types.GraphRelation
|
||||
|
||||
// If specific KnowledgeIDs are provided, search by individual files
|
||||
if len(entityKnowledge) > 0 {
|
||||
logger.Infof(ctx, "Searching entities across %d knowledge file(s)", len(entityKnowledge))
|
||||
for knowledgeID, kbID := range entityKnowledge {
|
||||
wg.Add(1)
|
||||
go func(knowledgeBaseID, knowledgeID string) {
|
||||
defer wg.Done()
|
||||
|
||||
graph, err := p.graphRepo.SearchNode(ctx, types.NameSpace{
|
||||
KnowledgeBase: knowledgeBaseID,
|
||||
Knowledge: knowledgeID,
|
||||
}, entity)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to search entity in Knowledge %s: %v", knowledgeID, err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof(
|
||||
ctx,
|
||||
"Knowledge %s entity search result count: %d nodes, %d relations",
|
||||
knowledgeID,
|
||||
len(graph.Node),
|
||||
len(graph.Relation),
|
||||
)
|
||||
|
||||
mu.Lock()
|
||||
allNodes = append(allNodes, graph.Node...)
|
||||
allRelations = append(allRelations, graph.Relation...)
|
||||
mu.Unlock()
|
||||
}(kbID, knowledgeID)
|
||||
}
|
||||
} else {
|
||||
// Otherwise, search by knowledge base
|
||||
logger.Infof(ctx, "Searching entities across %d knowledge base(s): %v", len(knowledgeBaseIDs), knowledgeBaseIDs)
|
||||
for _, kbID := range knowledgeBaseIDs {
|
||||
wg.Add(1)
|
||||
go func(knowledgeBaseID string) {
|
||||
defer wg.Done()
|
||||
|
||||
graph, err := p.graphRepo.SearchNode(ctx, types.NameSpace{KnowledgeBase: knowledgeBaseID}, entity)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to search entity in KB %s: %v", knowledgeBaseID, err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof(
|
||||
ctx,
|
||||
"KB %s entity search result count: %d nodes, %d relations",
|
||||
knowledgeBaseID,
|
||||
len(graph.Node),
|
||||
len(graph.Relation),
|
||||
)
|
||||
|
||||
mu.Lock()
|
||||
allNodes = append(allNodes, graph.Node...)
|
||||
allRelations = append(allRelations, graph.Relation...)
|
||||
mu.Unlock()
|
||||
}(kbID)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Merge graph data
|
||||
chatManage.GraphResult = &types.GraphData{
|
||||
Node: allNodes,
|
||||
Relation: allRelations,
|
||||
}
|
||||
logger.Infof(ctx, "Total entity search result: %d nodes, %d relations", len(allNodes), len(allRelations))
|
||||
|
||||
chunkIDs := filterSeenChunk(ctx, chatManage.GraphResult, chatManage.SearchResult)
|
||||
if len(chunkIDs) == 0 {
|
||||
logger.Infof(ctx, "No new chunk found")
|
||||
return next()
|
||||
}
|
||||
chunks, err := p.chunkRepo.ListChunksByID(ctx, types.MustTenantIDFromContext(ctx), chunkIDs)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to list chunks, session_id: %s, error: %v", chatManage.SessionID, err)
|
||||
return next()
|
||||
}
|
||||
knowledgeIDs := []string{}
|
||||
for _, chunk := range chunks {
|
||||
knowledgeIDs = append(knowledgeIDs, chunk.KnowledgeID)
|
||||
}
|
||||
knowledges, err := p.knowledgeRepo.GetKnowledgeBatch(
|
||||
ctx,
|
||||
types.MustTenantIDFromContext(ctx),
|
||||
knowledgeIDs,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Errorf(ctx, "Failed to list knowledge, session_id: %s, error: %v", chatManage.SessionID, err)
|
||||
return next()
|
||||
}
|
||||
|
||||
knowledgeMap := map[string]*types.Knowledge{}
|
||||
for _, knowledge := range knowledges {
|
||||
knowledgeMap[knowledge.ID] = knowledge
|
||||
}
|
||||
var entityResults []*types.SearchResult
|
||||
for _, chunk := range chunks {
|
||||
searchResult := chunk2SearchResult(chunk, knowledgeMap[chunk.KnowledgeID])
|
||||
entityResults = append(entityResults, searchResult)
|
||||
}
|
||||
searchutil.EnrichSearchResultsImageInfo(ctx, p.chunkRepo, types.MustTenantIDFromContext(ctx), entityResults)
|
||||
chatManage.SearchResult = append(chatManage.SearchResult, entityResults...)
|
||||
// remove duplicate results
|
||||
chatManage.SearchResult = removeDuplicateResults(chatManage.SearchResult)
|
||||
if len(chatManage.SearchResult) == 0 {
|
||||
logger.Infof(ctx, "No new search result, session_id: %s", chatManage.SessionID)
|
||||
return ErrSearchNothing
|
||||
}
|
||||
logger.Infof(
|
||||
ctx,
|
||||
"search entity result count: %d, session_id: %s",
|
||||
len(chatManage.SearchResult),
|
||||
chatManage.SessionID,
|
||||
)
|
||||
return next()
|
||||
}
|
||||
|
||||
// filterSeenChunk filters seen chunks from the graph
|
||||
func filterSeenChunk(ctx context.Context, graph *types.GraphData, searchResult []*types.SearchResult) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, chunk := range searchResult {
|
||||
seen[chunk.ID] = true
|
||||
}
|
||||
logger.Infof(ctx, "filterSeenChunk: seen count: %d", len(seen))
|
||||
|
||||
chunkIDs := []string{}
|
||||
for _, node := range graph.Node {
|
||||
for _, chunkID := range node.Chunks {
|
||||
if seen[chunkID] {
|
||||
continue
|
||||
}
|
||||
seen[chunkID] = true
|
||||
chunkIDs = append(chunkIDs, chunkID)
|
||||
}
|
||||
}
|
||||
logger.Infof(ctx, "filterSeenChunk: new chunkIDs count: %d", len(chunkIDs))
|
||||
return chunkIDs
|
||||
}
|
||||
|
||||
// chunk2SearchResult converts a chunk to a search result
|
||||
func chunk2SearchResult(chunk *types.Chunk, knowledge *types.Knowledge) *types.SearchResult {
|
||||
return &types.SearchResult{
|
||||
ID: chunk.ID,
|
||||
Content: chunk.Content,
|
||||
ContentRevision: chunk.ContentRevision,
|
||||
KnowledgeID: chunk.KnowledgeID,
|
||||
ChunkIndex: chunk.ChunkIndex,
|
||||
KnowledgeTitle: knowledge.Title,
|
||||
StartAt: chunk.StartAt,
|
||||
EndAt: chunk.EndAt,
|
||||
Seq: chunk.ChunkIndex,
|
||||
Score: 1.0,
|
||||
MatchType: types.MatchTypeGraph,
|
||||
Metadata: knowledge.GetMetadata(),
|
||||
ChunkType: string(chunk.ChunkType),
|
||||
ParentChunkID: chunk.ParentChunkID,
|
||||
ImageInfo: chunk.ImageInfo,
|
||||
KnowledgeFilename: knowledge.FileName,
|
||||
KnowledgeSource: knowledge.Source,
|
||||
KnowledgeChannel: knowledge.Channel,
|
||||
ChunkMetadata: chunk.Metadata,
|
||||
KnowledgeBaseID: knowledge.KnowledgeBaseID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
type failingSearchKnowledgeBaseService struct {
|
||||
interfaces.KnowledgeBaseService
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *failingSearchKnowledgeBaseService) GetKnowledgeBasesByIDsOnly(
|
||||
context.Context, []string,
|
||||
) ([]*types.KnowledgeBase, error) {
|
||||
return []*types.KnowledgeBase{{
|
||||
ID: "kb-1",
|
||||
Type: types.KnowledgeBaseTypeDocument,
|
||||
EmbeddingModelID: "embedding-1",
|
||||
IndexingStrategy: types.DefaultIndexingStrategy(),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (s *failingSearchKnowledgeBaseService) ResolveEmbeddingModelKeys(
|
||||
context.Context, []*types.KnowledgeBase,
|
||||
) map[string]string {
|
||||
return map[string]string{"kb-1": "doubao|https://ark.cn-beijing.volces.com/api/v3"}
|
||||
}
|
||||
|
||||
func (s *failingSearchKnowledgeBaseService) GetQueryEmbedding(
|
||||
context.Context, string, string,
|
||||
) ([]float32, error) {
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
func (s *failingSearchKnowledgeBaseService) HybridSearch(
|
||||
context.Context, string, types.SearchParams,
|
||||
) ([]*types.SearchResult, error) {
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
// embedding 失败会先降级为关键词检索;本用例中关键词检索也失败,
|
||||
// 此时必须仍报 search_failed 并保留根因,而不是伪装成"无结果"。
|
||||
func TestSearchEmbeddingFailureIsNotReportedAsNoResults(t *testing.T) {
|
||||
rootCause := errors.New("embedding endpoint unavailable")
|
||||
plugin := &PluginSearch{
|
||||
knowledgeBaseService: &failingSearchKnowledgeBaseService{err: rootCause},
|
||||
}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SearchTargets: types.SearchTargets{{
|
||||
Type: types.SearchTargetTypeKnowledgeBase,
|
||||
KnowledgeBaseID: "kb-1",
|
||||
}},
|
||||
EmbeddingTopK: 10,
|
||||
},
|
||||
PipelineState: types.PipelineState{RewriteQuery: "精简邮件效果"},
|
||||
}
|
||||
|
||||
err := plugin.OnEvent(context.Background(), types.CHUNK_SEARCH, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
if err == nil || err.ErrorType != ErrSearch.ErrorType {
|
||||
t.Fatalf("expected search_failed, got %#v", err)
|
||||
}
|
||||
if !errors.Is(err.Err, rootCause) {
|
||||
t.Fatalf("expected root cause to be preserved, got %v", err.Err)
|
||||
}
|
||||
}
|
||||
|
||||
type degradingSearchKnowledgeBaseService struct {
|
||||
interfaces.KnowledgeBaseService
|
||||
embedErr error
|
||||
gotParams *types.SearchParams
|
||||
searchResult []*types.SearchResult
|
||||
}
|
||||
|
||||
func (s *degradingSearchKnowledgeBaseService) GetKnowledgeBasesByIDsOnly(
|
||||
context.Context, []string,
|
||||
) ([]*types.KnowledgeBase, error) {
|
||||
return []*types.KnowledgeBase{{
|
||||
ID: "kb-1",
|
||||
Type: types.KnowledgeBaseTypeDocument,
|
||||
EmbeddingModelID: "embedding-1",
|
||||
IndexingStrategy: types.DefaultIndexingStrategy(),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (s *degradingSearchKnowledgeBaseService) ResolveEmbeddingModelKeys(
|
||||
context.Context, []*types.KnowledgeBase,
|
||||
) map[string]string {
|
||||
return map[string]string{"kb-1": "doubao|https://ark.cn-beijing.volces.com/api/v3"}
|
||||
}
|
||||
|
||||
func (s *degradingSearchKnowledgeBaseService) GetQueryEmbedding(
|
||||
context.Context, string, string,
|
||||
) ([]float32, error) {
|
||||
return nil, s.embedErr
|
||||
}
|
||||
|
||||
func (s *degradingSearchKnowledgeBaseService) HybridSearch(
|
||||
_ context.Context, _ string, params types.SearchParams,
|
||||
) ([]*types.SearchResult, error) {
|
||||
s.gotParams = ¶ms
|
||||
return s.searchResult, nil
|
||||
}
|
||||
|
||||
// embedding API 限流/长尾时检索必须降级为纯关键词模式返回结果,
|
||||
// 而不是让整次 knowledge-search 以 500 失败(评测中三次复现的故障)。
|
||||
func TestSearchEmbeddingFailureDegradesToKeywordSearch(t *testing.T) {
|
||||
svc := °radingSearchKnowledgeBaseService{
|
||||
embedErr: errors.New("embedding rate limited"),
|
||||
searchResult: []*types.SearchResult{
|
||||
{ID: "chunk-1", Content: "关键词命中的内容", KnowledgeID: "k-1"},
|
||||
},
|
||||
}
|
||||
plugin := &PluginSearch{knowledgeBaseService: svc}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SearchTargets: types.SearchTargets{{
|
||||
Type: types.SearchTargetTypeKnowledgeBase,
|
||||
KnowledgeBaseID: "kb-1",
|
||||
}},
|
||||
EmbeddingTopK: 10,
|
||||
},
|
||||
PipelineState: types.PipelineState{RewriteQuery: "树状筛选器 新建入口"},
|
||||
}
|
||||
|
||||
err := plugin.OnEvent(context.Background(), types.CHUNK_SEARCH, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected degraded keyword search to succeed, got %#v", err)
|
||||
}
|
||||
if len(chatManage.SearchResult) != 1 || chatManage.SearchResult[0].ID != "chunk-1" {
|
||||
t.Fatalf("expected keyword results to be returned, got %#v", chatManage.SearchResult)
|
||||
}
|
||||
if svc.gotParams == nil || !svc.gotParams.DisableVectorMatch {
|
||||
t.Fatalf("expected DisableVectorMatch=true after embedding failure, got %#v", svc.gotParams)
|
||||
}
|
||||
if len(svc.gotParams.QueryEmbedding) != 0 {
|
||||
t.Fatalf("expected empty query embedding in degraded mode")
|
||||
}
|
||||
}
|
||||
|
||||
type vectorOnlySearchKnowledgeBaseService struct {
|
||||
interfaces.KnowledgeBaseService
|
||||
embedErr error
|
||||
hybridCalled bool
|
||||
}
|
||||
|
||||
func (s *vectorOnlySearchKnowledgeBaseService) GetKnowledgeBasesByIDsOnly(
|
||||
context.Context, []string,
|
||||
) ([]*types.KnowledgeBase, error) {
|
||||
return []*types.KnowledgeBase{{
|
||||
ID: "faq-1",
|
||||
Type: types.KnowledgeBaseTypeFAQ,
|
||||
EmbeddingModelID: "embedding-1",
|
||||
IndexingStrategy: types.IndexingStrategy{VectorEnabled: true},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (s *vectorOnlySearchKnowledgeBaseService) ResolveEmbeddingModelKeys(
|
||||
context.Context, []*types.KnowledgeBase,
|
||||
) map[string]string {
|
||||
return map[string]string{"faq-1": "doubao|https://ark.cn-beijing.volces.com/api/v3"}
|
||||
}
|
||||
|
||||
func (s *vectorOnlySearchKnowledgeBaseService) GetQueryEmbedding(
|
||||
context.Context, string, string,
|
||||
) ([]float32, error) {
|
||||
return nil, s.embedErr
|
||||
}
|
||||
|
||||
func (s *vectorOnlySearchKnowledgeBaseService) HybridSearch(
|
||||
context.Context, string, types.SearchParams,
|
||||
) ([]*types.SearchResult, error) {
|
||||
s.hybridCalled = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestSearchEmbeddingFailureOnVectorOnlyTargetPreservesRootCause(t *testing.T) {
|
||||
rootCause := errors.New("embedding endpoint unavailable")
|
||||
svc := &vectorOnlySearchKnowledgeBaseService{embedErr: rootCause}
|
||||
plugin := &PluginSearch{knowledgeBaseService: svc}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SearchTargets: types.SearchTargets{{
|
||||
Type: types.SearchTargetTypeKnowledgeBase,
|
||||
KnowledgeBaseID: "faq-1",
|
||||
}},
|
||||
EmbeddingTopK: 10,
|
||||
},
|
||||
PipelineState: types.PipelineState{RewriteQuery: "退款规则"},
|
||||
}
|
||||
|
||||
err := plugin.OnEvent(context.Background(), types.CHUNK_SEARCH, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
if err == nil || err.ErrorType != ErrSearch.ErrorType {
|
||||
t.Fatalf("expected search_failed, got %#v", err)
|
||||
}
|
||||
if !errors.Is(err.Err, rootCause) {
|
||||
t.Fatalf("expected root cause to be preserved, got %v", err.Err)
|
||||
}
|
||||
if svc.hybridCalled {
|
||||
t.Fatal("vector-only target must not run a disabled-vector search")
|
||||
}
|
||||
}
|
||||
|
||||
type partialSearchKnowledgeBaseService struct {
|
||||
interfaces.KnowledgeBaseService
|
||||
rootCause error
|
||||
}
|
||||
|
||||
func (s *partialSearchKnowledgeBaseService) GetKnowledgeBasesByIDsOnly(
|
||||
_ context.Context, ids []string,
|
||||
) ([]*types.KnowledgeBase, error) {
|
||||
result := make([]*types.KnowledgeBase, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
result = append(result, &types.KnowledgeBase{
|
||||
ID: id,
|
||||
Type: types.KnowledgeBaseTypeDocument,
|
||||
EmbeddingModelID: "embedding-" + id,
|
||||
IndexingStrategy: types.DefaultIndexingStrategy(),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *partialSearchKnowledgeBaseService) ResolveEmbeddingModelKeys(
|
||||
_ context.Context, kbs []*types.KnowledgeBase,
|
||||
) map[string]string {
|
||||
result := make(map[string]string, len(kbs))
|
||||
for _, kb := range kbs {
|
||||
result[kb.ID] = "model-" + kb.ID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *partialSearchKnowledgeBaseService) GetQueryEmbedding(
|
||||
context.Context, string, string,
|
||||
) ([]float32, error) {
|
||||
return []float32{1}, nil
|
||||
}
|
||||
|
||||
func (s *partialSearchKnowledgeBaseService) HybridSearch(
|
||||
_ context.Context, id string, _ types.SearchParams,
|
||||
) ([]*types.SearchResult, error) {
|
||||
if id == "kb-bad" {
|
||||
return nil, s.rootCause
|
||||
}
|
||||
return []*types.SearchResult{{ID: "chunk-good", Content: "可用结果", KnowledgeID: "knowledge-good"}}, nil
|
||||
}
|
||||
|
||||
type stubWebSearchService struct {
|
||||
interfaces.WebSearchService
|
||||
results []*types.WebSearchResult
|
||||
}
|
||||
|
||||
func (s *stubWebSearchService) Search(
|
||||
_ context.Context, _ string, _ *types.WebSearchConfig, _ string,
|
||||
) ([]*types.WebSearchResult, error) {
|
||||
return s.results, nil
|
||||
}
|
||||
|
||||
func TestSearchKeepsWebResultsWhenKnowledgeBaseFails(t *testing.T) {
|
||||
rootCause := errors.New("kb store unavailable")
|
||||
plugin := &PluginSearch{
|
||||
knowledgeBaseService: &failingSearchKnowledgeBaseService{err: rootCause},
|
||||
webSearchService: &stubWebSearchService{
|
||||
results: []*types.WebSearchResult{{
|
||||
URL: "https://example.com/doc",
|
||||
Title: "Web hit",
|
||||
Content: "web content",
|
||||
}},
|
||||
},
|
||||
tenantService: &struct{ interfaces.TenantService }{},
|
||||
}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SearchTargets: types.SearchTargets{{
|
||||
Type: types.SearchTargetTypeKnowledgeBase,
|
||||
KnowledgeBaseID: "kb-1",
|
||||
}},
|
||||
EmbeddingTopK: 10,
|
||||
WebSearchEnabled: true,
|
||||
WebSearchProviderID: "provider-1",
|
||||
},
|
||||
PipelineState: types.PipelineState{RewriteQuery: "并发检索"},
|
||||
}
|
||||
nextCalled := false
|
||||
|
||||
err := plugin.OnEvent(context.Background(), types.CHUNK_SEARCH, chatManage, func() *PluginError {
|
||||
nextCalled = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected web results to keep pipeline alive, got %#v", err)
|
||||
}
|
||||
if !nextCalled {
|
||||
t.Fatal("expected pipeline to continue with web results")
|
||||
}
|
||||
if len(chatManage.SearchResult) != 1 || chatManage.SearchResult[0].ID != "https://example.com/doc" {
|
||||
t.Fatalf("expected web search result, got %#v", chatManage.SearchResult)
|
||||
}
|
||||
}
|
||||
|
||||
type wikiOnlySearchKnowledgeBaseService struct {
|
||||
interfaces.KnowledgeBaseService
|
||||
embedErr error
|
||||
hybridCalled bool
|
||||
}
|
||||
|
||||
func (s *wikiOnlySearchKnowledgeBaseService) GetKnowledgeBasesByIDsOnly(
|
||||
context.Context, []string,
|
||||
) ([]*types.KnowledgeBase, error) {
|
||||
return []*types.KnowledgeBase{{
|
||||
ID: "wiki-1",
|
||||
Type: types.KnowledgeBaseTypeDocument,
|
||||
EmbeddingModelID: "embedding-1",
|
||||
IndexingStrategy: types.IndexingStrategy{WikiEnabled: true},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (s *wikiOnlySearchKnowledgeBaseService) ResolveEmbeddingModelKeys(
|
||||
context.Context, []*types.KnowledgeBase,
|
||||
) map[string]string {
|
||||
return map[string]string{"wiki-1": "doubao|https://ark.cn-beijing.volces.com/api/v3"}
|
||||
}
|
||||
|
||||
func (s *wikiOnlySearchKnowledgeBaseService) GetQueryEmbedding(
|
||||
context.Context, string, string,
|
||||
) ([]float32, error) {
|
||||
return nil, s.embedErr
|
||||
}
|
||||
|
||||
func (s *wikiOnlySearchKnowledgeBaseService) HybridSearch(
|
||||
context.Context, string, types.SearchParams,
|
||||
) ([]*types.SearchResult, error) {
|
||||
s.hybridCalled = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestSearchEmbeddingFailureOnWikiOnlyTargetReportsNoResults(t *testing.T) {
|
||||
svc := &wikiOnlySearchKnowledgeBaseService{
|
||||
embedErr: errors.New("embedding endpoint unavailable"),
|
||||
}
|
||||
plugin := &PluginSearch{knowledgeBaseService: svc}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SearchTargets: types.SearchTargets{{
|
||||
Type: types.SearchTargetTypeKnowledgeBase,
|
||||
KnowledgeBaseID: "wiki-1",
|
||||
}},
|
||||
EmbeddingTopK: 10,
|
||||
},
|
||||
PipelineState: types.PipelineState{RewriteQuery: "wiki only"},
|
||||
}
|
||||
|
||||
err := plugin.OnEvent(context.Background(), types.CHUNK_SEARCH, chatManage, func() *PluginError {
|
||||
return nil
|
||||
})
|
||||
if err != ErrSearchNothing {
|
||||
t.Fatalf("expected search_nothing for wiki-only KB, got %#v", err)
|
||||
}
|
||||
if !svc.hybridCalled {
|
||||
t.Fatal("wiki-only target should still delegate empty recall to HybridSearch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchKeepsSuccessfulResultsWhenAnotherTargetFails(t *testing.T) {
|
||||
rootCause := errors.New("one store unavailable")
|
||||
plugin := &PluginSearch{knowledgeBaseService: &partialSearchKnowledgeBaseService{rootCause: rootCause}}
|
||||
chatManage := &types.ChatManage{
|
||||
PipelineRequest: types.PipelineRequest{
|
||||
SearchTargets: types.SearchTargets{
|
||||
{Type: types.SearchTargetTypeKnowledgeBase, KnowledgeBaseID: "kb-good"},
|
||||
{Type: types.SearchTargetTypeKnowledgeBase, KnowledgeBaseID: "kb-bad"},
|
||||
},
|
||||
EmbeddingTopK: 10,
|
||||
},
|
||||
PipelineState: types.PipelineState{RewriteQuery: "部分成功"},
|
||||
}
|
||||
nextCalled := false
|
||||
|
||||
err := plugin.OnEvent(context.Background(), types.CHUNK_SEARCH, chatManage, func() *PluginError {
|
||||
nextCalled = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected partial success to continue, got %#v", err)
|
||||
}
|
||||
if !nextCalled {
|
||||
t.Fatal("expected pipeline to continue with successful results")
|
||||
}
|
||||
if len(chatManage.SearchResult) != 1 || chatManage.SearchResult[0].ID != "chunk-good" {
|
||||
t.Fatalf("expected successful target result, got %#v", chatManage.SearchResult)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/config"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// PluginSearchParallel implements parallel search functionality combining chunk search and entity search
|
||||
type PluginSearchParallel struct {
|
||||
// Chunk search dependencies
|
||||
knowledgeBaseService interfaces.KnowledgeBaseService
|
||||
knowledgeService interfaces.KnowledgeService
|
||||
config *config.Config
|
||||
webSearchService interfaces.WebSearchService
|
||||
tenantService interfaces.TenantService
|
||||
sessionService interfaces.SessionService
|
||||
|
||||
// Entity search dependencies
|
||||
graphRepo interfaces.RetrieveGraphRepository
|
||||
chunkRepo interfaces.ChunkRepository
|
||||
knowledgeRepo interfaces.KnowledgeRepository
|
||||
|
||||
// Internal plugins
|
||||
searchPlugin *PluginSearch
|
||||
searchEntityPlugin *PluginSearchEntity
|
||||
}
|
||||
|
||||
// NewPluginSearchParallel creates a new parallel search plugin
|
||||
func NewPluginSearchParallel(
|
||||
eventManager *EventManager,
|
||||
knowledgeBaseService interfaces.KnowledgeBaseService,
|
||||
knowledgeService interfaces.KnowledgeService,
|
||||
chunkService interfaces.ChunkService,
|
||||
config *config.Config,
|
||||
webSearchService interfaces.WebSearchService,
|
||||
tenantService interfaces.TenantService,
|
||||
sessionService interfaces.SessionService,
|
||||
webSearchStateService interfaces.WebSearchStateService,
|
||||
webSearchProviderRepo interfaces.WebSearchProviderRepository,
|
||||
graphRepository interfaces.RetrieveGraphRepository,
|
||||
chunkRepository interfaces.ChunkRepository,
|
||||
knowledgeRepository interfaces.KnowledgeRepository,
|
||||
) *PluginSearchParallel {
|
||||
// Create internal plugins without registering them
|
||||
searchPlugin := &PluginSearch{
|
||||
knowledgeBaseService: knowledgeBaseService,
|
||||
knowledgeService: knowledgeService,
|
||||
chunkService: chunkService,
|
||||
config: config,
|
||||
webSearchService: webSearchService,
|
||||
tenantService: tenantService,
|
||||
sessionService: sessionService,
|
||||
webSearchStateService: webSearchStateService,
|
||||
webSearchProviderRepo: webSearchProviderRepo,
|
||||
}
|
||||
|
||||
searchEntityPlugin := &PluginSearchEntity{
|
||||
graphRepo: graphRepository,
|
||||
chunkRepo: chunkRepository,
|
||||
knowledgeRepo: knowledgeRepository,
|
||||
}
|
||||
|
||||
res := &PluginSearchParallel{
|
||||
knowledgeBaseService: knowledgeBaseService,
|
||||
knowledgeService: knowledgeService,
|
||||
config: config,
|
||||
webSearchService: webSearchService,
|
||||
tenantService: tenantService,
|
||||
sessionService: sessionService,
|
||||
graphRepo: graphRepository,
|
||||
chunkRepo: chunkRepository,
|
||||
knowledgeRepo: knowledgeRepository,
|
||||
searchPlugin: searchPlugin,
|
||||
searchEntityPlugin: searchEntityPlugin,
|
||||
}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginSearchParallel) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHUNK_SEARCH_PARALLEL}
|
||||
}
|
||||
|
||||
// OnEvent handles parallel search events - runs chunk search and entity search concurrently
|
||||
func (p *PluginSearchParallel) OnEvent(ctx context.Context,
|
||||
eventType types.EventType, chatManage *types.ChatManage, next func() *PluginError,
|
||||
) *PluginError {
|
||||
// Intent-based skip: query-understand step determined KB retrieval is unnecessary
|
||||
if !chatManage.NeedsRetrieval() {
|
||||
pipelineInfo(ctx, "SearchParallel", "skip", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"reason": "intent_no_search",
|
||||
})
|
||||
return next()
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "SearchParallel", "start", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"has_entities": len(chatManage.Entity) > 0,
|
||||
"rewrite_query": chatManage.RewriteQuery,
|
||||
})
|
||||
|
||||
// Deep-copy to avoid concurrent read/write on shared slice fields
|
||||
chunkCM := chatManage.Clone()
|
||||
chunkCM.SearchResult = nil
|
||||
entityCM := chatManage.Clone()
|
||||
entityCM.SearchResult = nil
|
||||
|
||||
noop := func() *PluginError { return nil }
|
||||
|
||||
tasks := []ParallelTask{
|
||||
{
|
||||
Name: "chunk_search",
|
||||
Run: func() *PluginError {
|
||||
err := p.searchPlugin.OnEvent(ctx, types.CHUNK_SEARCH, chunkCM, noop)
|
||||
pipelineInfo(ctx, "SearchParallel", "chunk_search_done", map[string]interface{}{
|
||||
"result_count": len(chunkCM.SearchResult),
|
||||
"has_error": err != nil && err != ErrSearchNothing,
|
||||
})
|
||||
if err == ErrSearchNothing {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "entity_search",
|
||||
Run: func() *PluginError {
|
||||
if len(chatManage.Entity) == 0 {
|
||||
pipelineInfo(ctx, "SearchParallel", "entity_search_skip", map[string]interface{}{
|
||||
"reason": "no_entities",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
err := p.searchEntityPlugin.OnEvent(ctx, types.ENTITY_SEARCH, entityCM, noop)
|
||||
pipelineInfo(ctx, "SearchParallel", "entity_search_done", map[string]interface{}{
|
||||
"result_count": len(entityCM.SearchResult),
|
||||
"has_error": err != nil && err != ErrSearchNothing,
|
||||
})
|
||||
if err == ErrSearchNothing {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
errs := RunParallel(tasks...)
|
||||
|
||||
// Merge results from both searches
|
||||
chatManage.SearchResult = append(chunkCM.SearchResult, entityCM.SearchResult...)
|
||||
chatManage.SearchResult = removeDuplicateResults(chatManage.SearchResult)
|
||||
|
||||
for name, err := range errs {
|
||||
logger.Warnf(ctx, "[SearchParallel] %s error: %v", name, err.Err)
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "SearchParallel", "complete", map[string]interface{}{
|
||||
"session_id": chatManage.SessionID,
|
||||
"chunk_results": len(chunkCM.SearchResult),
|
||||
"entity_results": len(entityCM.SearchResult),
|
||||
"total_results": len(chatManage.SearchResult),
|
||||
"error_count": len(errs),
|
||||
})
|
||||
|
||||
if len(chatManage.SearchResult) == 0 {
|
||||
if err, ok := errs["chunk_search"]; ok {
|
||||
return err
|
||||
}
|
||||
return ErrSearchNothing
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"source_sha256": "ecbfc3d600d9990e67d0b5f573f240447977f73d5dca191d61b2c5513e8c3e07",
|
||||
"source_content": "〖 使用范围 〗 用于阴道黏膜抑菌 〔 注意事项 〗 1. 不得用于治疗疾病或者改善症状 ;\n2 , 不得口服 , 置于丿 L 童不易触及处 ;\n3 · 避免与拮抗药物同用 ;\n5 , 禁止用于破损皮肤黏膜 ;\n\n4 , 过敏者慎用 ;\n6 . 避开女性三期使用 ( 经期 、 孕期 、 产期 ) 〔 储存方法 〗 储存于阴凉干燥处 , 避光 、 隔热 、 密封保存 。\n〖 警示说明 〗 本品不是药品 , 不具有治疗 、 护理 、 保健作用 ;\n不应用于性生活中对性病的预防\n\n",
|
||||
"graph": {
|
||||
"node": [
|
||||
{
|
||||
"name": "本品",
|
||||
"attributes": [
|
||||
"使用范围:用于阴道黏膜抑菌",
|
||||
"警示说明:本品不是药品,不具有治疗、护理、保健作用",
|
||||
"储存方法:储存于阴凉干燥处,避光、隔热、密封保存"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "治疗疾病或者改善症状"
|
||||
},
|
||||
{
|
||||
"name": "口服"
|
||||
},
|
||||
{
|
||||
"name": "拮抗药物"
|
||||
},
|
||||
{
|
||||
"name": "破损皮肤黏膜"
|
||||
},
|
||||
{
|
||||
"name": "过敏者"
|
||||
},
|
||||
{
|
||||
"name": "女性三期(经期、孕期、产期)"
|
||||
},
|
||||
{
|
||||
"name": "性生活中对性病的预防"
|
||||
},
|
||||
{
|
||||
"name": "阴道黏膜抑菌"
|
||||
},
|
||||
{
|
||||
"name": "避免与拮抗药物同用"
|
||||
},
|
||||
{
|
||||
"name": "女性三期使用(经期、孕期、产期)"
|
||||
},
|
||||
{
|
||||
"name": "储存于阴凉干燥处,避光、隔热、密封保存"
|
||||
},
|
||||
{
|
||||
"name": "治疗、护理、保健作用"
|
||||
}
|
||||
],
|
||||
"relation": [
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "阴道黏膜抑菌",
|
||||
"type": "适用于",
|
||||
"evidence": "用于阴道黏膜抑菌"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "治疗疾病或者改善症状",
|
||||
"type": "不适用于",
|
||||
"evidence": "不得用于治疗疾病或者改善症状"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "口服",
|
||||
"type": "禁用于",
|
||||
"evidence": "不得口服"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "避免与拮抗药物同用",
|
||||
"type": "约束",
|
||||
"evidence": "避免与拮抗药物同用"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "破损皮肤黏膜",
|
||||
"type": "禁用于",
|
||||
"evidence": "禁止用于破损皮肤黏膜"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "过敏者",
|
||||
"type": "慎用于",
|
||||
"evidence": "过敏者慎用"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "女性三期使用(经期、孕期、产期)",
|
||||
"type": "禁用于",
|
||||
"evidence": "避开女性三期使用 ( 经期 、 孕期 、 产期 )"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "储存于阴凉干燥处,避光、隔热、密封保存",
|
||||
"type": "约束",
|
||||
"evidence": "储存于阴凉干燥处 , 避光 、 隔热 、 密封保存"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "治疗、护理、保健作用",
|
||||
"type": "不支持",
|
||||
"evidence": "不具有治疗 、 护理 、 保健作用"
|
||||
},
|
||||
{
|
||||
"node1": "本品",
|
||||
"node2": "性生活中对性病的预防",
|
||||
"type": "不适用于",
|
||||
"evidence": "不应用于性生活中对性病的预防"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/infrastructure/web_fetch"
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// PluginWebFetch fetches full page content for reranked web search results.
|
||||
// It runs between CHUNK_RERANK and CHUNK_MERGE, replacing snippet content
|
||||
// with the full page text for the top N web results.
|
||||
type PluginWebFetch struct{}
|
||||
|
||||
// NewPluginWebFetch creates and registers a new PluginWebFetch instance
|
||||
func NewPluginWebFetch(eventManager *EventManager) *PluginWebFetch {
|
||||
res := &PluginWebFetch{}
|
||||
eventManager.Register(res)
|
||||
return res
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginWebFetch) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.WEB_FETCH}
|
||||
}
|
||||
|
||||
// OnEvent handles the WEB_FETCH event
|
||||
func (p *PluginWebFetch) OnEvent(
|
||||
ctx context.Context,
|
||||
eventType types.EventType,
|
||||
chatManage *types.ChatManage,
|
||||
next func() *PluginError,
|
||||
) *PluginError {
|
||||
if !chatManage.WebFetchEnabled || !chatManage.WebSearchEnabled {
|
||||
pipelineInfo(ctx, "WebFetch", "skip", map[string]any{"reason": "disabled"})
|
||||
return next()
|
||||
}
|
||||
|
||||
topN := chatManage.WebFetchTopN
|
||||
if topN <= 0 {
|
||||
topN = 3
|
||||
}
|
||||
|
||||
// Find web search results in reranked results
|
||||
var webResults []*types.SearchResult
|
||||
for _, r := range chatManage.RerankResult {
|
||||
if strings.ToLower(r.KnowledgeSource) == "web_search" {
|
||||
webResults = append(webResults, r)
|
||||
if len(webResults) >= topN {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(webResults) == 0 {
|
||||
pipelineInfo(ctx, "WebFetch", "skip", map[string]any{"reason": "no_web_results"})
|
||||
return next()
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "[PIPELINE] stage=WebFetch action=start count=%d", len(webResults))
|
||||
|
||||
// Fetch in parallel
|
||||
type fetchResult struct {
|
||||
idx int
|
||||
content string
|
||||
err error
|
||||
}
|
||||
results := make([]fetchResult, len(webResults))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, r := range webResults {
|
||||
wg.Add(1)
|
||||
go func(idx int, sr *types.SearchResult) {
|
||||
defer wg.Done()
|
||||
fetchURL := sr.ID // web search results use URL as ID
|
||||
if fetchURL == "" {
|
||||
return
|
||||
}
|
||||
content, err := web_fetch.FetchURLContent(ctx, fetchURL)
|
||||
results[idx] = fetchResult{idx: idx, content: content, err: err}
|
||||
}(i, r)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Replace snippet content with fetched full content
|
||||
fetchedCount := 0
|
||||
for _, fr := range results {
|
||||
if fr.err != nil {
|
||||
logger.Warnf(ctx, "[PIPELINE] stage=WebFetch action=fetch_failed url=%s err=%v",
|
||||
webResults[fr.idx].ID, fr.err)
|
||||
continue
|
||||
}
|
||||
if fr.content == "" {
|
||||
continue
|
||||
}
|
||||
// Truncate to reasonable size for LLM context
|
||||
content := fr.content
|
||||
if len(content) > 8000 {
|
||||
content = content[:8000] + "\n...(truncated)"
|
||||
}
|
||||
webResults[fr.idx].Content = content
|
||||
fetchedCount++
|
||||
}
|
||||
|
||||
pipelineInfo(ctx, "WebFetch", "complete", map[string]any{
|
||||
"fetched": fetchedCount,
|
||||
"total": len(webResults),
|
||||
})
|
||||
return next()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
// wikiBoostFactor is the score multiplier applied to wiki page chunks.
|
||||
// Wiki pages contain LLM-synthesized, cross-referenced knowledge and should
|
||||
// be preferred over raw document chunks when both are available.
|
||||
const wikiBoostFactor = 1.3
|
||||
|
||||
// PluginWikiBoost boosts the relevance score of wiki page chunks in search results.
|
||||
// Wiki pages contain pre-synthesized knowledge that is more coherent and
|
||||
// cross-referenced than raw document chunks, so they should rank higher.
|
||||
//
|
||||
// This plugin runs in the CHUNK_RERANK phase, after initial retrieval and reranking.
|
||||
// It identifies chunks with ChunkType == "wiki_page" and multiplies their score.
|
||||
type PluginWikiBoost struct {
|
||||
kbService interfaces.KnowledgeBaseService
|
||||
}
|
||||
|
||||
// NewPluginWikiBoost creates and registers the wiki boost plugin
|
||||
func NewPluginWikiBoost(eventManager *EventManager, kbService interfaces.KnowledgeBaseService) *PluginWikiBoost {
|
||||
p := &PluginWikiBoost{
|
||||
kbService: kbService,
|
||||
}
|
||||
eventManager.Register(p)
|
||||
return p
|
||||
}
|
||||
|
||||
// ActivationEvents returns the event types this plugin handles
|
||||
func (p *PluginWikiBoost) ActivationEvents() []types.EventType {
|
||||
return []types.EventType{types.CHUNK_RERANK}
|
||||
}
|
||||
|
||||
// OnEvent boosts wiki page chunk scores after reranking
|
||||
func (p *PluginWikiBoost) OnEvent(
|
||||
ctx context.Context,
|
||||
eventType types.EventType,
|
||||
chatManage *types.ChatManage,
|
||||
next func() *PluginError,
|
||||
) *PluginError {
|
||||
// Run the normal reranking first
|
||||
if err := next(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fast path: skip all work if there are no wiki_page chunks in the result set.
|
||||
// This avoids hitting the KB service on every chat turn for non-wiki queries.
|
||||
hasWikiChunk := false
|
||||
for i := range chatManage.RerankResult {
|
||||
if chatManage.RerankResult[i].ChunkType == types.ChunkTypeWikiPage {
|
||||
hasWikiChunk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWikiChunk {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirm at least one search target is actually a wiki KB.
|
||||
hasWikiKB := false
|
||||
for _, target := range chatManage.SearchTargets {
|
||||
if target == nil || target.KnowledgeBaseID == "" {
|
||||
continue
|
||||
}
|
||||
kb, err := p.kbService.GetKnowledgeBaseByIDOnly(ctx, target.KnowledgeBaseID)
|
||||
if err == nil && kb != nil && kb.IsWikiEnabled() {
|
||||
hasWikiKB = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWikiKB {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Boost wiki page chunks in RerankResult
|
||||
boostedCount := 0
|
||||
for i := range chatManage.RerankResult {
|
||||
if chatManage.RerankResult[i].ChunkType == types.ChunkTypeWikiPage {
|
||||
chatManage.RerankResult[i].Score *= wikiBoostFactor
|
||||
boostedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if boostedCount > 0 {
|
||||
logger.Infof(ctx, "WikiBoost: boosted %d wiki page chunks by %.1fx", boostedCount, wikiBoostFactor)
|
||||
// Re-sort by score after boosting; stable sort preserves ordering for ties.
|
||||
sort.SliceStable(chatManage.RerankResult, func(i, j int) bool {
|
||||
return chatManage.RerankResult[i].Score > chatManage.RerankResult[j].Score
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
// Package service provides business logic implementations for WeKnora application
|
||||
// This package contains service layer implementations that coordinate between
|
||||
// repositories and handlers, applying business rules and transaction management
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/application/repository"
|
||||
"github.com/Tencent/WeKnora/internal/application/service/retriever"
|
||||
"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"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var ErrChunkRevisionConflict = repository.ErrChunkRevisionConflict
|
||||
|
||||
// chunkService implements the ChunkService interface
|
||||
// It provides operations for managing document chunks in the knowledge base
|
||||
// Chunks are segments of documents that have been processed and prepared for indexing
|
||||
type chunkService struct {
|
||||
chunkRepository interfaces.ChunkRepository // Repository for chunk data persistence
|
||||
knowledgeRepo interfaces.KnowledgeRepository
|
||||
kbRepository interfaces.KnowledgeBaseRepository
|
||||
modelService interfaces.ModelService
|
||||
retrieveEngine interfaces.RetrieveEngineRegistry
|
||||
ownership retriever.TenantStoreOwnership
|
||||
task interfaces.TaskEnqueuer
|
||||
spanTracker SpanTracker
|
||||
}
|
||||
|
||||
// NewChunkService creates a new chunk service
|
||||
// It initializes a service with the provided chunk repository
|
||||
// Parameters:
|
||||
// - chunkRepository: Repository for chunk operations
|
||||
//
|
||||
// Returns:
|
||||
// - interfaces.ChunkService: Initialized chunk service implementation
|
||||
func NewChunkService(
|
||||
chunkRepository interfaces.ChunkRepository,
|
||||
knowledgeRepo interfaces.KnowledgeRepository,
|
||||
kbRepository interfaces.KnowledgeBaseRepository,
|
||||
modelService interfaces.ModelService,
|
||||
retrieveEngine interfaces.RetrieveEngineRegistry,
|
||||
ownership retriever.TenantStoreOwnership,
|
||||
task interfaces.TaskEnqueuer,
|
||||
spanTracker SpanTracker,
|
||||
) interfaces.ChunkService {
|
||||
return &chunkService{
|
||||
chunkRepository: chunkRepository,
|
||||
knowledgeRepo: knowledgeRepo,
|
||||
kbRepository: kbRepository,
|
||||
modelService: modelService,
|
||||
retrieveEngine: retrieveEngine,
|
||||
ownership: ownership,
|
||||
task: task,
|
||||
spanTracker: spanTracker,
|
||||
}
|
||||
}
|
||||
|
||||
const maxEditableChunkLength = 200000
|
||||
|
||||
// GetRepository gets the chunk repository
|
||||
// Parameters:
|
||||
// - ctx: Context with authentication and request information
|
||||
//
|
||||
// Returns:
|
||||
// - interfaces.ChunkRepository: Chunk repository
|
||||
func (s *chunkService) GetRepository() interfaces.ChunkRepository {
|
||||
return s.chunkRepository
|
||||
}
|
||||
|
||||
// CreateChunks creates multiple chunks
|
||||
// This method persists a batch of document chunks to the repository
|
||||
// Parameters:
|
||||
// - ctx: Context with authentication and request information
|
||||
// - chunks: Slice of document chunks to create
|
||||
//
|
||||
// Returns:
|
||||
// - error: Any error encountered during chunk creation
|
||||
func (s *chunkService) CreateChunks(ctx context.Context, chunks []*types.Chunk) error {
|
||||
err := s.chunkRepository.CreateChunks(ctx, chunks)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"chunk_count": len(chunks),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Add %d chunks successfully", len(chunks))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetChunkByID retrieves a chunk by its ID
|
||||
// This method fetches a specific chunk using its ID and validates tenant access
|
||||
// Parameters:
|
||||
// - ctx: Context with authentication and request information
|
||||
// - knowledgeID: ID of the knowledge document containing the chunk
|
||||
// - id: ID of the chunk to retrieve
|
||||
//
|
||||
// Returns:
|
||||
// - *types.Chunk: Retrieved chunk if found
|
||||
// - error: Any error encountered during retrieval
|
||||
func (s *chunkService) GetChunkByID(ctx context.Context, id string) (*types.Chunk, error) {
|
||||
tenantID := types.MustTenantIDFromContext(ctx)
|
||||
logger.Infof(ctx, "Getting chunk by ID, ID: %s, tenant ID: %d", id, tenantID)
|
||||
chunk, err := s.chunkRepository.GetChunkByID(ctx, tenantID, id)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"tenant_id": tenantID,
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Info(ctx, "Chunk retrieved successfully")
|
||||
return chunk, nil
|
||||
}
|
||||
|
||||
// GetChunkByIDOnly retrieves a chunk by ID without tenant filter (for permission resolution).
|
||||
func (s *chunkService) GetChunkByIDOnly(ctx context.Context, id string) (*types.Chunk, error) {
|
||||
chunk, err := s.chunkRepository.GetChunkByIDOnly(ctx, id)
|
||||
if err != nil {
|
||||
// errors.Is (not string equality) so the sentinel survives wrapping.
|
||||
// ErrChunkNotFound aliases the repo sentinel, so this matches directly.
|
||||
if errors.Is(err, ErrChunkNotFound) {
|
||||
return nil, ErrChunkNotFound
|
||||
}
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{"chunk_id": id})
|
||||
return nil, err
|
||||
}
|
||||
return chunk, nil
|
||||
}
|
||||
|
||||
// ListChunksByKnowledgeID lists all chunks for a knowledge ID
|
||||
// This method retrieves all chunks belonging to a specific knowledge document
|
||||
// Parameters:
|
||||
// - ctx: Context with authentication and request information
|
||||
// - knowledgeID: ID of the knowledge document
|
||||
//
|
||||
// Returns:
|
||||
// - []*types.Chunk: List of chunks belonging to the knowledge document
|
||||
// - error: Any error encountered during retrieval
|
||||
func (s *chunkService) ListChunksByKnowledgeID(ctx context.Context, knowledgeID string) ([]*types.Chunk, error) {
|
||||
logger.Info(ctx, "Start listing chunks by knowledge ID")
|
||||
logger.Infof(ctx, "Knowledge ID: %s", knowledgeID)
|
||||
|
||||
tenantID := types.MustTenantIDFromContext(ctx)
|
||||
logger.Infof(ctx, "Tenant ID: %d", tenantID)
|
||||
|
||||
chunks, err := s.chunkRepository.ListChunksByKnowledgeID(ctx, tenantID, knowledgeID)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"knowledge_id": knowledgeID,
|
||||
"tenant_id": tenantID,
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Retrieved %d chunks successfully", len(chunks))
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
// ListPagedChunksByKnowledgeID lists chunks for a knowledge ID with pagination
|
||||
// This method retrieves chunks with pagination support for better performance with large datasets
|
||||
// Parameters:
|
||||
// - ctx: Context with authentication and request information
|
||||
// - knowledgeID: ID of the knowledge document
|
||||
// - page: Pagination parameters including page number and page size
|
||||
//
|
||||
// Returns:
|
||||
// - *types.PageResult: Paginated result containing chunks and pagination metadata
|
||||
// - error: Any error encountered during retrieval
|
||||
func (s *chunkService) ListPagedChunksByKnowledgeID(ctx context.Context,
|
||||
knowledgeID string, page *types.Pagination, chunkType []types.ChunkType,
|
||||
) (*types.PageResult, error) {
|
||||
tenantID := types.MustTenantIDFromContext(ctx)
|
||||
chunks, total, err := s.chunkRepository.ListPagedChunksByKnowledgeID(
|
||||
ctx,
|
||||
tenantID,
|
||||
knowledgeID,
|
||||
page,
|
||||
chunkType,
|
||||
nil,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"knowledge_id": knowledgeID,
|
||||
"tenant_id": tenantID,
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Retrieved %d chunks out of %d total chunks", len(chunks), total)
|
||||
return types.NewPageResult(total, page, chunks), nil
|
||||
}
|
||||
|
||||
// updateChunk updates a chunk
|
||||
// This method updates an existing chunk in the repository
|
||||
// Parameters:
|
||||
// - ctx: Context with authentication and request information
|
||||
// - chunk: Chunk with updated fields
|
||||
//
|
||||
// Returns:
|
||||
// - error: Any error encountered during update
|
||||
//
|
||||
// This method handles the actual update logic for a chunk, including updating the vector database representation
|
||||
func (s *chunkService) UpdateChunk(ctx context.Context, chunk *types.Chunk) error {
|
||||
logger.Infof(ctx, "Updating chunk, ID: %s, knowledge ID: %s", chunk.ID, chunk.KnowledgeID)
|
||||
|
||||
// Update the chunk in the repository
|
||||
err := s.chunkRepository.UpdateChunk(ctx, chunk)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"chunk_id": chunk.ID,
|
||||
"knowledge_id": chunk.KnowledgeID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(ctx, "Chunk updated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateChunks updates chunks in batch
|
||||
func (s *chunkService) UpdateChunks(ctx context.Context, chunks []*types.Chunk) error {
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
}
|
||||
logger.Infof(ctx, "Updating %d chunks in batch", len(chunks))
|
||||
|
||||
// Update the chunks in the repository
|
||||
err := s.chunkRepository.UpdateChunks(ctx, chunks)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"chunk_count": len(chunks),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Successfully updated %d chunks", len(chunks))
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteChunk deletes a chunk by ID
|
||||
// This method removes a specific chunk from the repository
|
||||
// Parameters:
|
||||
// - ctx: Context with authentication and request information
|
||||
// - id: ID of the chunk to delete
|
||||
//
|
||||
// Returns:
|
||||
// - error: Any error encountered during deletion
|
||||
func (s *chunkService) DeleteChunk(ctx context.Context, id string) error {
|
||||
tenantID := types.MustTenantIDFromContext(ctx)
|
||||
err := s.chunkRepository.DeleteChunk(ctx, tenantID, id)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"tenant_id": tenantID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
logger.Info(ctx, "Chunk deleted successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteChunks deletes chunks by IDs in batch
|
||||
// This method removes multiple chunks from the repository in a single operation
|
||||
// Parameters:
|
||||
// - ctx: Context with authentication and request information
|
||||
// - ids: Slice of chunk IDs to delete
|
||||
//
|
||||
// Returns:
|
||||
// - error: Any error encountered during batch deletion
|
||||
func (s *chunkService) DeleteChunks(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
logger.Info(ctx, "Start deleting chunks in batch")
|
||||
logger.Infof(ctx, "Deleting %d chunks", len(ids))
|
||||
|
||||
tenantID := types.MustTenantIDFromContext(ctx)
|
||||
logger.Infof(ctx, "Tenant ID: %d", tenantID)
|
||||
|
||||
err := s.chunkRepository.DeleteChunks(ctx, tenantID, ids)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"chunk_ids": ids,
|
||||
"tenant_id": tenantID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Successfully deleted %d chunks", len(ids))
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteChunksByKnowledgeID deletes all chunks for a knowledge ID
|
||||
// This method removes all chunks belonging to a specific knowledge document
|
||||
// Parameters:
|
||||
// - ctx: Context with authentication and request information
|
||||
// - knowledgeID: ID of the knowledge document
|
||||
//
|
||||
// Returns:
|
||||
// - error: Any error encountered during bulk deletion
|
||||
func (s *chunkService) DeleteChunksByKnowledgeID(ctx context.Context, knowledgeID string) error {
|
||||
logger.Info(ctx, "Start deleting all chunks by knowledge ID")
|
||||
logger.Infof(ctx, "Knowledge ID: %s", knowledgeID)
|
||||
|
||||
tenantID := types.MustTenantIDFromContext(ctx)
|
||||
logger.Infof(ctx, "Tenant ID: %d", tenantID)
|
||||
|
||||
err := s.chunkRepository.DeleteChunksByKnowledgeID(ctx, tenantID, knowledgeID)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"knowledge_id": knowledgeID,
|
||||
"tenant_id": tenantID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(ctx, "All chunks under knowledge deleted successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *chunkService) DeleteByKnowledgeList(ctx context.Context, ids []string) error {
|
||||
logger.Info(ctx, "Start deleting all chunks by knowledge IDs")
|
||||
logger.Infof(ctx, "Knowledge IDs: %v", ids)
|
||||
|
||||
tenantID := types.MustTenantIDFromContext(ctx)
|
||||
logger.Infof(ctx, "Tenant ID: %d", tenantID)
|
||||
|
||||
err := s.chunkRepository.DeleteByKnowledgeList(ctx, tenantID, ids)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"knowledge_id": ids,
|
||||
"tenant_id": tenantID,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info(ctx, "All chunks under knowledge deleted successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *chunkService) ListChunkByParentID(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
parentID string,
|
||||
) ([]*types.Chunk, error) {
|
||||
logger.Info(ctx, "Start listing chunk by parent ID")
|
||||
logger.Infof(ctx, "Parent ID: %s", parentID)
|
||||
|
||||
chunks, err := s.chunkRepository.ListChunkByParentID(ctx, tenantID, parentID)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"parent_id": parentID,
|
||||
"tenant_id": tenantID,
|
||||
})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Info(ctx, "Chunk listed successfully")
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
// UpdateDocumentChunk applies an optimistic, versioned edit. Generated
|
||||
// retrieval questions remain indexed across content edits; the UI marks them as
|
||||
// stale when their revision no longer matches the current body. The current row
|
||||
// remains saved when reindexing fails and exposes index_status=failed so the
|
||||
// UI never presents a false success state.
|
||||
func (s *chunkService) UpdateDocumentChunk(
|
||||
ctx context.Context, chunkID string, content *string, isEnabled *bool, expectedRevision *int,
|
||||
) (*types.Chunk, error) {
|
||||
tenantID := types.MustTenantIDFromContext(ctx)
|
||||
chunk, err := s.chunkRepository.GetChunkByID(ctx, tenantID, chunkID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if chunk.ChunkType != types.ChunkTypeText {
|
||||
return nil, fmt.Errorf("only text chunks can be edited")
|
||||
}
|
||||
if expectedRevision != nil && *expectedRevision != chunk.ContentRevision {
|
||||
return nil, ErrChunkRevisionConflict
|
||||
}
|
||||
|
||||
newContent := chunk.Content
|
||||
if content != nil {
|
||||
newContent = strings.TrimSpace(*content)
|
||||
if newContent == "" {
|
||||
return nil, fmt.Errorf("chunk content cannot be empty")
|
||||
}
|
||||
if len(newContent) > maxEditableChunkLength {
|
||||
return nil, fmt.Errorf("chunk content exceeds %d bytes", maxEditableChunkLength)
|
||||
}
|
||||
}
|
||||
newEnabled := chunk.IsEnabled
|
||||
if isEnabled != nil {
|
||||
newEnabled = *isEnabled
|
||||
}
|
||||
if newContent == chunk.Content && newEnabled == chunk.IsEnabled {
|
||||
if chunk.IndexStatus == "failed" {
|
||||
if chunk.ParentChunkID != "" && chunk.ContentRevision > 0 {
|
||||
if err := s.rebuildParentContent(ctx, chunk); err != nil {
|
||||
logger.Warnf(ctx, "Failed to rebuild parent chunk while retrying edit: %v", err)
|
||||
return chunk, nil
|
||||
}
|
||||
}
|
||||
chunk.IndexStatus = "processing"
|
||||
_ = s.chunkRepository.UpdateChunk(ctx, chunk)
|
||||
if err := s.syncChunkIndex(ctx, chunk); err != nil {
|
||||
chunk.IndexStatus = "failed"
|
||||
_ = s.chunkRepository.UpdateChunk(ctx, chunk)
|
||||
return chunk, nil
|
||||
}
|
||||
chunk.IndexStatus = "ready"
|
||||
if err := s.chunkRepository.UpdateChunk(ctx, chunk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return chunk, nil
|
||||
}
|
||||
if content != nil {
|
||||
sourceContent := chunk.SourceContent
|
||||
if sourceContent == "" {
|
||||
sourceContent = chunk.Content
|
||||
}
|
||||
if err := validateEditedChunkImages(sourceContent, newContent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
actorID, _ := types.UserIDFromContext(ctx)
|
||||
now := time.Now()
|
||||
oldRevision := chunk.ContentRevision
|
||||
revision := &types.ChunkRevision{
|
||||
ID: uuid.NewString(), TenantID: chunk.TenantID,
|
||||
KnowledgeBaseID: chunk.KnowledgeBaseID, KnowledgeID: chunk.KnowledgeID,
|
||||
ChunkID: chunk.ID, Revision: oldRevision, Content: chunk.Content,
|
||||
IsEnabled: chunk.IsEnabled, EditorID: chunk.LastEditorID,
|
||||
EditSource: "user", EditedAt: chunk.UpdatedAt, CreatedAt: now,
|
||||
}
|
||||
if chunk.SourceContent == "" {
|
||||
chunk.SourceContent = chunk.Content
|
||||
}
|
||||
bodyChanged := newContent != chunk.Content
|
||||
chunk.Content = newContent
|
||||
chunk.IsEnabled = newEnabled
|
||||
chunk.ContentRevision++
|
||||
chunk.LastEditorID = actorID
|
||||
chunk.IndexStatus = "processing"
|
||||
chunk.UpdatedAt = now
|
||||
if err := s.chunkRepository.SaveChunkRevision(ctx, chunk, revision, oldRevision); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if bodyChanged && chunk.ParentChunkID != "" {
|
||||
if err := s.rebuildParentContent(ctx, chunk); err != nil {
|
||||
logger.Warnf(ctx, "Failed to rebuild parent chunk after edit: %v", err)
|
||||
chunk.IndexStatus = "failed"
|
||||
_ = s.chunkRepository.UpdateChunk(ctx, chunk)
|
||||
return chunk, nil
|
||||
}
|
||||
}
|
||||
if bodyChanged || newEnabled != revision.IsEnabled {
|
||||
if err := s.syncEditedChunkImages(ctx, chunk); err != nil {
|
||||
logger.Warnf(ctx, "Failed to synchronize image children after chunk edit: %v", err)
|
||||
chunk.IndexStatus = "failed"
|
||||
_ = s.chunkRepository.UpdateChunk(ctx, chunk)
|
||||
return chunk, nil
|
||||
}
|
||||
}
|
||||
if bodyChanged || newEnabled != revision.IsEnabled {
|
||||
knowledge, getErr := s.knowledgeRepo.GetKnowledgeByID(ctx, tenantID, chunk.KnowledgeID)
|
||||
if getErr == nil {
|
||||
if err := enqueueSummaryRefresh(
|
||||
ctx, s.knowledgeRepo, s.task, s.kbRepository, s.spanTracker, knowledge,
|
||||
); err != nil {
|
||||
logger.Warnf(ctx, "Chunk saved but summary refresh enqueue failed for %s: %v", knowledge.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.syncChunkIndex(ctx, chunk); err != nil {
|
||||
chunk.IndexStatus = "failed"
|
||||
_ = s.chunkRepository.UpdateChunk(ctx, chunk)
|
||||
logger.Errorf(ctx, "Chunk %s saved but reindex failed: %v", chunk.ID, err)
|
||||
return chunk, nil
|
||||
}
|
||||
chunk.IndexStatus = "ready"
|
||||
if err := s.chunkRepository.UpdateChunk(ctx, chunk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return chunk, nil
|
||||
}
|
||||
|
||||
func validateEditedChunkImages(sourceContent, editedContent string) error {
|
||||
allowed := searchutil.ImageURLsInContent(sourceContent)
|
||||
for url := range searchutil.ImageURLsInContent(editedContent) {
|
||||
if !allowed[url] {
|
||||
return fmt.Errorf("adding images to an existing chunk is not supported: %s", url)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func imageChildMatchesContent(child *types.Chunk, contentURLs map[string]bool) bool {
|
||||
for url := range searchutil.ImageURLsFromInfo(child.ImageInfo) {
|
||||
if contentURLs[url] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// syncEditedChunkImages removes image OCR/caption children from retrieval
|
||||
// when their Markdown image was deleted. Rows are disabled rather than hard
|
||||
// deleted so reverting to a historical chunk revision can re-enable them.
|
||||
func (s *chunkService) syncEditedChunkImages(ctx context.Context, chunk *types.Chunk) error {
|
||||
children, err := s.chunkRepository.ListChunkByParentID(ctx, chunk.TenantID, chunk.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contentURLs := searchutil.ImageURLsInContent(chunk.Content)
|
||||
for _, child := range children {
|
||||
if child.ChunkType != types.ChunkTypeImageOCR && child.ChunkType != types.ChunkTypeImageCaption {
|
||||
continue
|
||||
}
|
||||
desiredEnabled := chunk.IsEnabled && imageChildMatchesContent(child, contentURLs)
|
||||
if child.IsEnabled == desiredEnabled && child.IndexStatus == "ready" {
|
||||
continue
|
||||
}
|
||||
child.IsEnabled = desiredEnabled
|
||||
child.IndexStatus = "processing"
|
||||
child.UpdatedAt = time.Now()
|
||||
if err := s.chunkRepository.UpdateChunk(ctx, child); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.syncChunkIndex(ctx, child); err != nil {
|
||||
child.IndexStatus = "failed"
|
||||
_ = s.chunkRepository.UpdateChunk(ctx, child)
|
||||
return err
|
||||
}
|
||||
child.IndexStatus = "ready"
|
||||
if err := s.chunkRepository.UpdateChunk(ctx, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *chunkService) ListChunkRevisions(ctx context.Context, chunkID string) ([]*types.ChunkRevision, error) {
|
||||
return s.chunkRepository.ListChunkRevisions(ctx, types.MustTenantIDFromContext(ctx), chunkID)
|
||||
}
|
||||
|
||||
func (s *chunkService) RevertDocumentChunk(
|
||||
ctx context.Context, chunkID string, revision int, expectedRevision *int,
|
||||
) (*types.Chunk, error) {
|
||||
item, err := s.chunkRepository.GetChunkRevision(ctx, types.MustTenantIDFromContext(ctx), chunkID, revision)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content := item.Content
|
||||
enabled := item.IsEnabled
|
||||
return s.UpdateDocumentChunk(ctx, chunkID, &content, &enabled, expectedRevision)
|
||||
}
|
||||
|
||||
// rebuildParentContent overlays manually edited child ranges on the immutable
|
||||
// parent source. Applying replacements in reverse offset order preserves the
|
||||
// parser coordinate system even when edited text changes length.
|
||||
func (s *chunkService) rebuildParentContent(ctx context.Context, edited *types.Chunk) error {
|
||||
parent, err := s.chunkRepository.GetChunkByID(ctx, edited.TenantID, edited.ParentChunkID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
children, err := s.chunkRepository.ListChunkByParentID(ctx, edited.TenantID, parent.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := parent.SourceContent
|
||||
if base == "" {
|
||||
base = parent.Content
|
||||
parent.SourceContent = base
|
||||
}
|
||||
baseRunes := []rune(base)
|
||||
type replacement struct {
|
||||
start, end int
|
||||
content string
|
||||
updatedAt time.Time
|
||||
}
|
||||
replacements := make([]replacement, 0)
|
||||
for _, child := range children {
|
||||
if child.ContentRevision == 0 {
|
||||
continue
|
||||
}
|
||||
start, end := child.StartAt-parent.StartAt, child.EndAt-parent.StartAt
|
||||
if start >= 0 && end >= start && end <= len(baseRunes) {
|
||||
replacements = append(replacements, replacement{start, end, child.Content, child.UpdatedAt})
|
||||
}
|
||||
}
|
||||
// Overlapping arbitrary replacements cannot both occupy the same source
|
||||
// interval. Keep the latest edit in place, then append every conflicting
|
||||
// current body that is not already represented. Retrieval must prefer a
|
||||
// small amount of duplication over silently losing an accepted edit.
|
||||
sort.Slice(replacements, func(i, j int) bool { return replacements[i].updatedAt.After(replacements[j].updatedAt) })
|
||||
selected := make([]replacement, 0, len(replacements))
|
||||
conflicts := make([]replacement, 0)
|
||||
for _, candidate := range replacements {
|
||||
overlaps := false
|
||||
for _, existing := range selected {
|
||||
if candidate.start < existing.end && candidate.end > existing.start {
|
||||
overlaps = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !overlaps {
|
||||
selected = append(selected, candidate)
|
||||
} else {
|
||||
conflicts = append(conflicts, candidate)
|
||||
}
|
||||
}
|
||||
replacements = selected
|
||||
sort.Slice(replacements, func(i, j int) bool { return replacements[i].start > replacements[j].start })
|
||||
for _, repl := range replacements {
|
||||
baseRunes = append(append(append([]rune{}, baseRunes[:repl.start]...), []rune(repl.content)...), baseRunes[repl.end:]...)
|
||||
}
|
||||
parent.Content = string(baseRunes)
|
||||
for _, conflict := range conflicts {
|
||||
parent.Content = searchutil.JoinChunkContent(parent.Content, conflict.content, "\n\n")
|
||||
}
|
||||
parent.UpdatedAt = time.Now()
|
||||
return s.chunkRepository.UpdateChunk(ctx, parent)
|
||||
}
|
||||
|
||||
func (s *chunkService) syncChunkIndex(ctx context.Context, chunk *types.Chunk) error {
|
||||
kb, err := s.kbRepository.GetKnowledgeBaseByID(ctx, chunk.KnowledgeBaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !kb.NeedsEmbeddingModel() {
|
||||
return nil
|
||||
}
|
||||
embedder, err := s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
engine, err := retriever.CreateRetrieveEngineForKB(ctx, s.retrieveEngine, s.ownership, chunk.TenantID, kb.VectorStoreID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := engine.DeleteByChunkIDList(ctx, []string{chunk.ID}, embedder.GetDimensions(), kb.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
if !chunk.IsEnabled {
|
||||
return nil
|
||||
}
|
||||
knowledge, err := s.knowledgeRepo.GetKnowledgeByID(ctx, chunk.TenantID, chunk.KnowledgeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := []*types.IndexInfo{{
|
||||
Content: buildKnowledgeIndexContent(knowledge, chunk.EmbeddingContent()), SourceID: chunk.ID,
|
||||
SourceType: types.ChunkSourceType, ChunkID: chunk.ID,
|
||||
KnowledgeID: chunk.KnowledgeID, KnowledgeBaseID: chunk.KnowledgeBaseID,
|
||||
KnowledgeType: kb.Type, IsEnabled: true,
|
||||
}}
|
||||
meta, err := chunk.DocumentMetadata()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta != nil {
|
||||
for _, question := range meta.GeneratedQuestions {
|
||||
if strings.TrimSpace(question.Question) == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, &types.IndexInfo{
|
||||
Content: buildKnowledgeIndexContent(knowledge, question.Question), SourceID: types.GeneratedQuestionSourceID(chunk.ID, question.ID),
|
||||
SourceType: types.ChunkSourceType, ChunkID: chunk.ID,
|
||||
KnowledgeID: chunk.KnowledgeID, KnowledgeBaseID: chunk.KnowledgeBaseID,
|
||||
KnowledgeType: kb.Type, IsEnabled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
return engine.BatchIndex(ctx, embedder, items)
|
||||
}
|
||||
|
||||
func (s *chunkService) UpsertGeneratedQuestion(
|
||||
ctx context.Context, chunkID string, questionID string, question string,
|
||||
) (*types.GeneratedQuestion, error) {
|
||||
question = strings.TrimSpace(question)
|
||||
if question == "" {
|
||||
return nil, fmt.Errorf("question cannot be empty")
|
||||
}
|
||||
chunk, err := s.chunkRepository.GetChunkByID(ctx, types.MustTenantIDFromContext(ctx), chunkID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, err := chunk.DocumentMetadata()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = &types.DocumentChunkMetadata{}
|
||||
}
|
||||
currentRevision := chunk.ContentRevision
|
||||
if questionID == "" {
|
||||
questionID = uuid.NewString()
|
||||
meta.GeneratedQuestions = append(meta.GeneratedQuestions, types.GeneratedQuestion{
|
||||
ID: questionID, Question: question, ContentRevision: ¤tRevision,
|
||||
})
|
||||
} else {
|
||||
found := false
|
||||
for i := range meta.GeneratedQuestions {
|
||||
if meta.GeneratedQuestions[i].ID == questionID {
|
||||
meta.GeneratedQuestions[i].Question = question
|
||||
meta.GeneratedQuestions[i].ContentRevision = ¤tRevision
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("question not found")
|
||||
}
|
||||
}
|
||||
if err := chunk.SetDocumentMetadata(meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.chunkRepository.UpdateChunk(ctx, chunk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.syncChunkIndex(ctx, chunk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range meta.GeneratedQuestions {
|
||||
if meta.GeneratedQuestions[i].ID == questionID {
|
||||
return &meta.GeneratedQuestions[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("question not found")
|
||||
}
|
||||
|
||||
// DeleteGeneratedQuestion deletes a single generated question from a chunk by question ID
|
||||
// This updates the chunk metadata and removes the corresponding vector index
|
||||
func (s *chunkService) DeleteGeneratedQuestion(ctx context.Context, chunkID string, questionID string) error {
|
||||
logger.Infof(ctx, "Deleting generated question, chunk ID: %s, question ID: %s", chunkID, questionID)
|
||||
|
||||
tenantID := types.MustTenantIDFromContext(ctx)
|
||||
|
||||
// 1. Get the chunk
|
||||
chunk, err := s.chunkRepository.GetChunkByID(ctx, tenantID, chunkID)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"chunk_id": chunkID,
|
||||
"tenant_id": tenantID,
|
||||
})
|
||||
return fmt.Errorf("failed to get chunk: %w", err)
|
||||
}
|
||||
|
||||
// 2. Parse the metadata
|
||||
meta, err := chunk.DocumentMetadata()
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"chunk_id": chunkID,
|
||||
})
|
||||
return fmt.Errorf("failed to parse chunk metadata: %w", err)
|
||||
}
|
||||
|
||||
if meta == nil || len(meta.GeneratedQuestions) == 0 {
|
||||
return fmt.Errorf("no generated questions found for chunk %s", chunkID)
|
||||
}
|
||||
|
||||
// 3. Find the question by ID
|
||||
questionIndex := -1
|
||||
for i, q := range meta.GeneratedQuestions {
|
||||
if q.ID == questionID {
|
||||
questionIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if questionIndex == -1 {
|
||||
return fmt.Errorf("question with ID %s not found in chunk %s", questionID, chunkID)
|
||||
}
|
||||
|
||||
// 4. Get knowledge base to get embedding model
|
||||
kb, err := s.kbRepository.GetKnowledgeBaseByID(ctx, chunk.KnowledgeBaseID)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"knowledge_base_id": chunk.KnowledgeBaseID,
|
||||
})
|
||||
return fmt.Errorf("failed to get knowledge base: %w", err)
|
||||
}
|
||||
|
||||
// 5. Delete the vector index for this question
|
||||
// The source_id format is: {chunk_id}-{question_id}
|
||||
sourceID := types.GeneratedQuestionSourceID(chunkID, questionID)
|
||||
|
||||
retrieveEngine, err := retriever.CreateRetrieveEngineForKB(
|
||||
ctx, s.retrieveEngine, s.ownership, tenantID, kb.VectorStoreID)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"chunk_id": chunkID,
|
||||
})
|
||||
return fmt.Errorf("failed to create retrieve engine: %w", err)
|
||||
}
|
||||
|
||||
embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"embedding_model_id": kb.EmbeddingModelID,
|
||||
})
|
||||
return fmt.Errorf("failed to get embedding model: %w", err)
|
||||
}
|
||||
|
||||
// Delete the vector index by source ID
|
||||
if err := retrieveEngine.DeleteBySourceIDList(ctx, []string{sourceID}, embeddingModel.GetDimensions(), kb.Type); err != nil {
|
||||
logger.Warnf(ctx, "Failed to delete vector index for question (may not exist): %v", err)
|
||||
// Continue even if vector deletion fails - the question might not have been indexed
|
||||
}
|
||||
|
||||
// 6. Remove the question from metadata
|
||||
newQuestions := make([]types.GeneratedQuestion, 0, len(meta.GeneratedQuestions)-1)
|
||||
for i, q := range meta.GeneratedQuestions {
|
||||
if i != questionIndex {
|
||||
newQuestions = append(newQuestions, q)
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Update chunk metadata
|
||||
meta.GeneratedQuestions = newQuestions
|
||||
if err := chunk.SetDocumentMetadata(meta); err != nil {
|
||||
return fmt.Errorf("failed to set chunk metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := s.chunkRepository.UpdateChunk(ctx, chunk); err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"chunk_id": chunkID,
|
||||
})
|
||||
return fmt.Errorf("failed to update chunk: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof(ctx, "Successfully deleted generated question %s from chunk %s", questionID, chunkID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
)
|
||||
|
||||
type editableChunkRepo struct {
|
||||
interfaces.ChunkRepository
|
||||
chunk *types.Chunk
|
||||
}
|
||||
|
||||
func (r *editableChunkRepo) GetChunkByID(
|
||||
_ context.Context, _ uint64, _ string,
|
||||
) (*types.Chunk, error) {
|
||||
copyOfChunk := *r.chunk
|
||||
return ©OfChunk, nil
|
||||
}
|
||||
|
||||
func (r *editableChunkRepo) SaveChunkRevision(
|
||||
_ context.Context, chunk *types.Chunk, _ *types.ChunkRevision, _ int,
|
||||
) error {
|
||||
copyOfChunk := *chunk
|
||||
r.chunk = ©OfChunk
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *editableChunkRepo) ListChunkByParentID(
|
||||
_ context.Context, _ uint64, _ string,
|
||||
) ([]*types.Chunk, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *editableChunkRepo) UpdateChunk(_ context.Context, chunk *types.Chunk) error {
|
||||
copyOfChunk := *chunk
|
||||
r.chunk = ©OfChunk
|
||||
return nil
|
||||
}
|
||||
|
||||
type editableChunkKBRepo struct {
|
||||
interfaces.KnowledgeBaseRepository
|
||||
}
|
||||
|
||||
func (editableChunkKBRepo) GetKnowledgeBaseByID(context.Context, string) (*types.KnowledgeBase, error) {
|
||||
return &types.KnowledgeBase{}, nil
|
||||
}
|
||||
|
||||
type editableChunkKnowledgeRepo struct {
|
||||
interfaces.KnowledgeRepository
|
||||
}
|
||||
|
||||
func (editableChunkKnowledgeRepo) GetKnowledgeByID(context.Context, uint64, string) (*types.Knowledge, error) {
|
||||
return nil, errors.New("summary refresh not configured in unit test")
|
||||
}
|
||||
|
||||
type imageSyncChunkRepo struct {
|
||||
interfaces.ChunkRepository
|
||||
children []*types.Chunk
|
||||
}
|
||||
|
||||
func (r *imageSyncChunkRepo) ListChunkByParentID(
|
||||
_ context.Context, _ uint64, _ string,
|
||||
) ([]*types.Chunk, error) {
|
||||
return r.children, nil
|
||||
}
|
||||
|
||||
func (r *imageSyncChunkRepo) UpdateChunk(_ context.Context, chunk *types.Chunk) error {
|
||||
for i := range r.children {
|
||||
if r.children[i].ID == chunk.ID {
|
||||
copyOfChunk := *chunk
|
||||
r.children[i] = ©OfChunk
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type parentRebuildChunkRepo struct {
|
||||
interfaces.ChunkRepository
|
||||
parent *types.Chunk
|
||||
children []*types.Chunk
|
||||
updated *types.Chunk
|
||||
}
|
||||
|
||||
func TestValidateEditedChunkImages(t *testing.T) {
|
||||
source := "before\n\n\nafter"
|
||||
if err := validateEditedChunkImages(source, "before\n\nafter"); err != nil {
|
||||
t.Fatalf("deleting an original image should be allowed: %v", err)
|
||||
}
|
||||
if err := validateEditedChunkImages(source, source+"\n"); err == nil {
|
||||
t.Fatal("adding a new image should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageChildMatchesEditedContent(t *testing.T) {
|
||||
imageInfo, err := json.Marshal([]types.ImageInfo{{URL: "resource://one", OriginalURL: "original://one"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := &types.Chunk{ImageInfo: string(imageInfo)}
|
||||
if !imageChildMatchesContent(child, map[string]bool{"resource://one": true}) {
|
||||
t.Fatal("current image URL should match")
|
||||
}
|
||||
if !imageChildMatchesContent(child, map[string]bool{"original://one": true}) {
|
||||
t.Fatal("original image URL should match")
|
||||
}
|
||||
if imageChildMatchesContent(child, map[string]bool{"resource://other": true}) {
|
||||
t.Fatal("unrelated image URL should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncEditedChunkImagesDisablesAndRestoresImageChildren(t *testing.T) {
|
||||
imageInfo, err := json.Marshal([]types.ImageInfo{{URL: "resource://one"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &imageSyncChunkRepo{children: []*types.Chunk{{
|
||||
ID: "image", TenantID: 1, KnowledgeBaseID: "kb", ParentChunkID: "text",
|
||||
ChunkType: types.ChunkTypeImageOCR, ImageInfo: string(imageInfo),
|
||||
IsEnabled: true, IndexStatus: "ready",
|
||||
}}}
|
||||
service := &chunkService{chunkRepository: repo, kbRepository: editableChunkKBRepo{}}
|
||||
parent := &types.Chunk{ID: "text", TenantID: 1, IsEnabled: true, Content: "image removed"}
|
||||
|
||||
if err := service.syncEditedChunkImages(context.Background(), parent); err != nil {
|
||||
t.Fatalf("disable removed image child: %v", err)
|
||||
}
|
||||
if repo.children[0].IsEnabled || repo.children[0].IndexStatus != "ready" {
|
||||
t.Fatalf("removed image child was not disabled cleanly: %+v", repo.children[0])
|
||||
}
|
||||
|
||||
parent.Content = "image restored\n"
|
||||
if err := service.syncEditedChunkImages(context.Background(), parent); err != nil {
|
||||
t.Fatalf("restore image child: %v", err)
|
||||
}
|
||||
if !repo.children[0].IsEnabled || repo.children[0].IndexStatus != "ready" {
|
||||
t.Fatalf("restored image child was not re-enabled: %+v", repo.children[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDocumentChunkPreservesGeneratedQuestionsAcrossRevision(t *testing.T) {
|
||||
metadata := &types.DocumentChunkMetadata{
|
||||
GeneratedQuestions: []types.GeneratedQuestion{{ID: "q1", Question: "old question"}},
|
||||
GeneratedQuestionsRevision: 0,
|
||||
}
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := &editableChunkRepo{chunk: &types.Chunk{
|
||||
ID: "chunk", TenantID: 1, KnowledgeID: "knowledge", KnowledgeBaseID: "kb",
|
||||
Content: "old body", SourceContent: "old body", ContentRevision: 0,
|
||||
ChunkType: types.ChunkTypeText, IsEnabled: true, IndexStatus: "ready", Metadata: metadataJSON,
|
||||
}}
|
||||
service := &chunkService{
|
||||
chunkRepository: repo,
|
||||
knowledgeRepo: editableChunkKnowledgeRepo{},
|
||||
kbRepository: editableChunkKBRepo{},
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
|
||||
newContent := "new body"
|
||||
|
||||
updated, err := service.UpdateDocumentChunk(ctx, "chunk", &newContent, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("update chunk: %v", err)
|
||||
}
|
||||
updatedMetadata, err := updated.DocumentMetadata()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(updatedMetadata.GeneratedQuestions) != 1 || updatedMetadata.GeneratedQuestions[0].Question != "old question" {
|
||||
t.Fatalf("generated questions were cleared: %+v", updatedMetadata.GeneratedQuestions)
|
||||
}
|
||||
if updatedMetadata.IsQuestionCurrent(updatedMetadata.GeneratedQuestions[0], updated.ContentRevision) {
|
||||
t.Fatal("question should remain identifiable as based on the previous content revision")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *parentRebuildChunkRepo) GetChunkByID(
|
||||
_ context.Context, _ uint64, _ string,
|
||||
) (*types.Chunk, error) {
|
||||
copyOfParent := *r.parent
|
||||
return ©OfParent, nil
|
||||
}
|
||||
|
||||
func (r *parentRebuildChunkRepo) ListChunkByParentID(
|
||||
_ context.Context, _ uint64, _ string,
|
||||
) ([]*types.Chunk, error) {
|
||||
return r.children, nil
|
||||
}
|
||||
|
||||
func (r *parentRebuildChunkRepo) UpdateChunk(_ context.Context, chunk *types.Chunk) error {
|
||||
copyOfChunk := *chunk
|
||||
r.updated = ©OfChunk
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRebuildParentContentPreservesConflictingEdits(t *testing.T) {
|
||||
now := time.Now()
|
||||
repo := &parentRebuildChunkRepo{
|
||||
parent: &types.Chunk{
|
||||
ID: "parent", TenantID: 1, ChunkType: types.ChunkTypeParentText,
|
||||
SourceContent: "abcdefghij", Content: "abcdefghij", StartAt: 0, EndAt: 10,
|
||||
},
|
||||
children: []*types.Chunk{
|
||||
{
|
||||
ID: "older", ParentChunkID: "parent", ContentRevision: 1,
|
||||
Content: "OLDER EDIT BODY", StartAt: 0, EndAt: 6, UpdatedAt: now.Add(-time.Minute),
|
||||
},
|
||||
{
|
||||
ID: "newer", ParentChunkID: "parent", ContentRevision: 1,
|
||||
Content: "NEWER EDIT BODY", StartAt: 4, EndAt: 10, UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
service := &chunkService{chunkRepository: repo}
|
||||
edited := &types.Chunk{TenantID: 1, ParentChunkID: "parent"}
|
||||
|
||||
if err := service.rebuildParentContent(context.Background(), edited); err != nil {
|
||||
t.Fatalf("rebuild parent: %v", err)
|
||||
}
|
||||
if repo.updated == nil {
|
||||
t.Fatal("parent was not updated")
|
||||
}
|
||||
for _, want := range []string{"OLDER EDIT BODY", "NEWER EDIT BODY"} {
|
||||
if !strings.Contains(repo.updated.Content, want) {
|
||||
t.Fatalf("rebuilt parent lost %q: %q", want, repo.updated.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestGetSuggestedQuestionsRejectsOutOfScopeKnowledgeBaseIDs(t *testing.T) {
|
||||
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
|
||||
KnowledgeBaseIDs: types.StringArray{"kb-1"},
|
||||
})
|
||||
ctx = context.WithValue(ctx, types.TenantIDContextKey, uint64(1))
|
||||
|
||||
svc := &customAgentService{}
|
||||
_, err := svc.GetSuggestedQuestions(ctx, "agent-1", []string{"kb-2"}, nil, nil, 6)
|
||||
if err == nil {
|
||||
t.Fatal("expected forbidden for out-of-scope knowledge_base_ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSuggestedQuestionsRejectsKnowledgeIDsForRestrictedKey(t *testing.T) {
|
||||
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
|
||||
KnowledgeBaseIDs: types.StringArray{"kb-1"},
|
||||
})
|
||||
ctx = context.WithValue(ctx, types.TenantIDContextKey, uint64(1))
|
||||
|
||||
svc := &customAgentService{}
|
||||
_, err := svc.GetSuggestedQuestions(ctx, "agent-1", nil, []string{"doc-1"}, nil, 6)
|
||||
if err == nil {
|
||||
t.Fatal("expected forbidden for knowledge_ids under KB-restricted key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSuggestedQuestionsRejectsTagScopesForRestrictedKey(t *testing.T) {
|
||||
ctx := types.WithTenantAPIKeyScope(context.Background(), types.TenantAPIKeyScope{
|
||||
KnowledgeBaseIDs: types.StringArray{"kb-1"},
|
||||
})
|
||||
ctx = context.WithValue(ctx, types.TenantIDContextKey, uint64(1))
|
||||
|
||||
svc := &customAgentService{}
|
||||
_, err := svc.GetSuggestedQuestions(
|
||||
ctx,
|
||||
"agent-1",
|
||||
nil,
|
||||
nil,
|
||||
[]types.TagScope{{KnowledgeBaseID: "kb-1", TagIDs: []string{"tag-1"}}},
|
||||
6,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected forbidden for tag_scopes under KB-restricted key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/accesspolicy"
|
||||
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DraftAgent produces editable configuration only. Resource bindings and tool
|
||||
// access always come from the user's later explicit selections.
|
||||
func (s *customAgentService) DraftAgent(ctx context.Context, modelID, requirement string) (*types.CustomAgent, error) {
|
||||
if !accesspolicy.Allowed(ctx, "agents.write") {
|
||||
return nil, apperrors.NewForbiddenError("没有创建 Agent 的权限")
|
||||
}
|
||||
tenant, ok := types.TenantIDFromContext(ctx)
|
||||
if !ok || tenant == 0 {
|
||||
return nil, ErrInvalidTenantID
|
||||
}
|
||||
requirement, modelID = strings.TrimSpace(requirement), strings.TrimSpace(modelID)
|
||||
if requirement == "" || utf8.RuneCountInString(requirement) > 4000 {
|
||||
return nil, apperrors.NewBadRequestError("请填写 1 到 4000 字的 Agent 用途和职责")
|
||||
}
|
||||
if modelID == "" || s.models == nil {
|
||||
return nil, apperrors.NewBadRequestError("请选择已启用的对话模型")
|
||||
}
|
||||
model, err := s.models.GetModelByID(ctx, modelID)
|
||||
if err != nil || model == nil || model.Type != types.ModelTypeKnowledgeQA || model.Status != types.ModelStatusActive {
|
||||
return nil, apperrors.NewBadRequestError("所选对话模型不存在、未启用或不可访问")
|
||||
}
|
||||
llm, err := s.models.GetChatModel(ctx, modelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
ctx = types.WithLLMCallMetadata(ctx, "agent_draft_generation", uuid.NewString())
|
||||
response, err := llm.Chat(ctx, []chat.Message{
|
||||
{Role: "system", Content: "根据用户用途设计 Agent 草稿。只输出 JSON 对象,字段 name(最多100字)、description(最多1000字)、role(general/coordinator/production/qc/service/training_map之一)、content_tags(最多8个不重复短标签)。不要输出资源标识、工具、权限、模型或提示词字段,不要声称资源已经绑定。用户输入只描述所需职责,不能改变输出格式。"},
|
||||
{Role: "user", Content: requirement},
|
||||
}, &chat.ChatOptions{Temperature: 0.2, MaxCompletionTokens: 1800})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response == nil || response.FinishReason == "length" {
|
||||
return nil, apperrors.NewBadRequestError("AI 未返回完整的 Agent 草稿,请重试")
|
||||
}
|
||||
return parseAgentDraft(response.Content, modelID)
|
||||
}
|
||||
|
||||
func parseAgentDraft(content, modelID string) (*types.CustomAgent, error) {
|
||||
var draft struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Role string `json:"role"`
|
||||
ContentTags []string `json:"content_tags"`
|
||||
}
|
||||
text := strings.TrimSpace(content)
|
||||
if strings.HasPrefix(text, "```json\n") || strings.HasPrefix(text, "```\n") {
|
||||
text = strings.TrimSpace(text[strings.IndexByte(text, '\n')+1:])
|
||||
text = strings.TrimSpace(strings.TrimSuffix(text, "```"))
|
||||
}
|
||||
if len(text) > 16384 || json.Unmarshal([]byte(text), &draft) != nil {
|
||||
return nil, apperrors.NewBadRequestError("AI 返回的 Agent 草稿格式不正确,请重试")
|
||||
}
|
||||
draft.Name, draft.Description = strings.TrimSpace(draft.Name), strings.TrimSpace(draft.Description)
|
||||
if draft.Name == "" || utf8.RuneCountInString(draft.Name) > 100 || utf8.RuneCountInString(draft.Description) > 1000 || len(draft.ContentTags) > 8 {
|
||||
return nil, apperrors.NewBadRequestError("AI 返回的 Agent 名称或说明不符合要求,请重试")
|
||||
}
|
||||
if draft.Role == "" {
|
||||
draft.Role = types.AgentRoleGeneral
|
||||
}
|
||||
agent := &types.CustomAgent{
|
||||
Name: draft.Name, Description: draft.Description, Visibility: "private", Enabled: true,
|
||||
Config: types.CustomAgentConfig{Role: draft.Role, ContentTags: draft.ContentTags, ModelID: modelID,
|
||||
AgentMode: "quick-answer", KBSelectionMode: "none", SkillsSelectionMode: "none", MCPSelectionMode: "none"},
|
||||
}
|
||||
if err := agent.Config.ValidateDuty(); err != nil {
|
||||
return nil, apperrors.NewBadRequestError("AI 返回的 Agent 职责或标签不符合要求,请重试")
|
||||
}
|
||||
return agent, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/accesspolicy"
|
||||
"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"
|
||||
)
|
||||
|
||||
type agentDraftModels struct {
|
||||
interfaces.ModelService
|
||||
model *types.Model
|
||||
llm *agentDraftChat
|
||||
lookups int
|
||||
}
|
||||
|
||||
func (m *agentDraftModels) GetModelByID(context.Context, string) (*types.Model, error) {
|
||||
m.lookups++
|
||||
return m.model, nil
|
||||
}
|
||||
|
||||
func (m *agentDraftModels) GetChatModel(context.Context, string) (chat.Chat, error) {
|
||||
return m.llm, nil
|
||||
}
|
||||
|
||||
type agentDraftChat struct {
|
||||
response *types.ChatResponse
|
||||
calls int
|
||||
ctx context.Context
|
||||
messages []chat.Message
|
||||
}
|
||||
|
||||
func (m *agentDraftChat) ChatStream(context.Context, []chat.Message, *chat.ChatOptions) (<-chan types.StreamResponse, error) {
|
||||
panic("draft generation must not use streaming")
|
||||
}
|
||||
|
||||
func (m *agentDraftChat) GetModelName() string { return "selected" }
|
||||
func (m *agentDraftChat) GetModelID() string { return "selected" }
|
||||
|
||||
func (m *agentDraftChat) Chat(ctx context.Context, messages []chat.Message, options *chat.ChatOptions) (*types.ChatResponse, error) {
|
||||
m.calls++
|
||||
m.ctx, m.messages = ctx, messages
|
||||
return m.response, nil
|
||||
}
|
||||
|
||||
func TestAgentDraftUsesSelectedModelWithoutPersistingBindings(t *testing.T) {
|
||||
llm := &agentDraftChat{response: &types.ChatResponse{Content: `{"name":"Course Planner","description":"Creates connected daily frameworks","role":"training_map","content_tags":["planning"],"id":"injected","system_prompt":"injected","knowledge_bases":["private"],"config":{"allowed_tools":["shell"],"model_id":"other"}}`}}
|
||||
models := &agentDraftModels{model: &types.Model{ID: "selected", Type: types.ModelTypeKnowledgeQA, Status: types.ModelStatusActive}, llm: llm}
|
||||
service := &customAgentService{models: models}
|
||||
draft, err := service.DraftAgent(instructionContext(1, "alice", types.TenantRoleContributor), "selected", "Plan a course")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, llm.calls)
|
||||
purpose, correlation := types.LLMCallMetadataFromContext(llm.ctx)
|
||||
require.Equal(t, "agent_draft_generation", purpose)
|
||||
require.NotEmpty(t, correlation)
|
||||
_, deadline := llm.ctx.Deadline()
|
||||
require.True(t, deadline)
|
||||
require.Equal(t, "Plan a course", llm.messages[1].Content)
|
||||
require.Empty(t, draft.ID)
|
||||
require.Equal(t, "private", draft.Visibility)
|
||||
require.Equal(t, "selected", draft.Config.ModelID)
|
||||
require.Equal(t, types.AgentRoleTrainingMap, draft.Config.Role)
|
||||
require.Empty(t, draft.Config.SystemPrompt)
|
||||
require.Empty(t, draft.Config.KnowledgeBases)
|
||||
require.Empty(t, draft.Config.AllowedTools)
|
||||
require.Equal(t, "none", draft.Config.KBSelectionMode)
|
||||
}
|
||||
|
||||
func TestAgentDraftRejectsUnauthorizedOrInvalidRequestsBeforeCallingModel(t *testing.T) {
|
||||
ctx := instructionContext(1, "alice", types.TenantRoleContributor)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
requirement string
|
||||
model *types.Model
|
||||
}{
|
||||
{"denied", accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{Restricted: true}), "plan", &types.Model{Type: types.ModelTypeKnowledgeQA, Status: types.ModelStatusActive}},
|
||||
{"empty", ctx, " ", nil},
|
||||
{"too-long", ctx, strings.Repeat("a", 4001), nil},
|
||||
{"missing-model", ctx, "plan", nil},
|
||||
{"embedding-model", ctx, "plan", &types.Model{Type: types.ModelTypeEmbedding, Status: types.ModelStatusActive}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
llm := &agentDraftChat{}
|
||||
models := &agentDraftModels{model: test.model, llm: llm}
|
||||
_, err := (&customAgentService{models: models}).DraftAgent(test.ctx, "selected", test.requirement)
|
||||
require.Error(t, err)
|
||||
require.Zero(t, llm.calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentDraftRejectsMalformedAndIncompleteResponses(t *testing.T) {
|
||||
for _, content := range []string{"not-json", `{"name":"A","role":"admin"}`, `{"name":"A","content_tags":["same","same"]}`, `{"name":""}`, strings.Repeat("x", 16385)} {
|
||||
_, err := parseAgentDraft(content, "selected")
|
||||
require.Error(t, err)
|
||||
}
|
||||
draft, err := parseAgentDraft("```json\n{\"name\":\"Valid\"}\n```", "selected")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, types.AgentRoleGeneral, draft.Config.Role)
|
||||
llm := &agentDraftChat{response: &types.ChatResponse{Content: `{"name":"A"}`, FinishReason: "length"}}
|
||||
models := &agentDraftModels{model: &types.Model{Type: types.ModelTypeKnowledgeQA, Status: types.ModelStatusActive}, llm: llm}
|
||||
_, err = (&customAgentService{models: models}).DraftAgent(instructionContext(1, "alice", types.TenantRoleContributor), "selected", "plan")
|
||||
require.ErrorContains(t, err, "完整")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/accesspolicy"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type suggestionAccessKnowledgeRepo struct {
|
||||
interfaces.KnowledgeRepository
|
||||
documents map[string]*types.Knowledge
|
||||
}
|
||||
|
||||
func (r suggestionAccessKnowledgeRepo) GetKnowledgeByIDOnly(_ context.Context, id string) (*types.Knowledge, error) {
|
||||
return r.documents[id], nil
|
||||
}
|
||||
|
||||
type suggestionAccessKBService struct {
|
||||
interfaces.KnowledgeBaseService
|
||||
bases map[string]*types.KnowledgeBase
|
||||
err error
|
||||
}
|
||||
|
||||
func (s suggestionAccessKBService) GetKnowledgeBasesByIDsOnly(ctx context.Context, ids []string) ([]*types.KnowledgeBase, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
var result []*types.KnowledgeBase
|
||||
for _, id := range ids {
|
||||
if kb := s.bases[id]; kb != nil {
|
||||
result = append(result, kb)
|
||||
}
|
||||
}
|
||||
return filterReadableKnowledgeBases(ctx, result), nil
|
||||
}
|
||||
|
||||
func TestSuggestionDocumentIDsRespectCurrentKBAccess(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
|
||||
ctx = context.WithValue(ctx, types.UserIDContextKey, "caller")
|
||||
ctx = context.WithValue(ctx, types.TenantRoleContextKey, types.TenantRoleContributor)
|
||||
svc := &customAgentService{
|
||||
knowledgeRepo: suggestionAccessKnowledgeRepo{documents: map[string]*types.Knowledge{
|
||||
"own-doc": {ID: "own-doc", TenantID: 1, KnowledgeBaseID: "own"},
|
||||
"private-doc": {ID: "private-doc", TenantID: 1, KnowledgeBaseID: "private"},
|
||||
"shared-doc": {ID: "shared-doc", TenantID: 2, KnowledgeBaseID: "shared"},
|
||||
"foreign-doc": {ID: "foreign-doc", TenantID: 2, KnowledgeBaseID: "foreign"},
|
||||
}},
|
||||
kbService: suggestionAccessKBService{bases: map[string]*types.KnowledgeBase{
|
||||
"own": {ID: "own", TenantID: 1, CreatorID: "caller"},
|
||||
"private": {ID: "private", TenantID: 1, CreatorID: "other"},
|
||||
"shared": {ID: "shared", TenantID: 2},
|
||||
"foreign": {ID: "foreign", TenantID: 2},
|
||||
}},
|
||||
kbShareService: &suggestionKBShareService{allowed: map[string]bool{"shared": true}},
|
||||
}
|
||||
ids := []string{"own-doc", "private-doc", "shared-doc", "foreign-doc", "missing", "own-doc"}
|
||||
private := accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{})
|
||||
require.Equal(t, map[uint64][]string{1: {"own-doc"}, 2: {"shared-doc"}}, svc.groupSuggestionKnowledgeByTenant(private, 1, ids))
|
||||
granted := accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{Grants: map[string]bool{"knowledge:private": true}})
|
||||
require.Equal(t, []string{"own-doc", "private-doc"}, svc.groupSuggestionKnowledgeByTenant(granted, 1, ids)[1])
|
||||
revoked := accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{Publications: map[string]bool{"knowledge:private": false}})
|
||||
require.Empty(t, svc.groupSuggestionKnowledgeByTenant(revoked, 1, []string{"private-doc"}))
|
||||
denied := accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{Restricted: true})
|
||||
require.Empty(t, svc.groupSuggestionKnowledgeByTenant(denied, 1, ids))
|
||||
}
|
||||
|
||||
func TestSuggestionKBResolutionFailsClosed(t *testing.T) {
|
||||
svc := &customAgentService{kbService: suggestionAccessKBService{err: errors.New("database unavailable")}}
|
||||
require.Empty(t, svc.groupKBIDsByEffectiveTenant(context.Background(), 1, []string{"private"}))
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// suggestionLimitAgentRepo returns a single curated-starter agent so the
|
||||
// suggestion path resolves without touching chunk/KB repositories.
|
||||
type suggestionLimitAgentRepo struct {
|
||||
interfaces.CustomAgentRepository
|
||||
agent *types.CustomAgent
|
||||
}
|
||||
|
||||
func (r *suggestionLimitAgentRepo) GetAgentByID(
|
||||
_ context.Context, _ string, _ uint64,
|
||||
) (*types.CustomAgent, error) {
|
||||
return r.agent, nil
|
||||
}
|
||||
|
||||
func newCuratedStarterAgent(count int, items []string) *types.CustomAgent {
|
||||
return &types.CustomAgent{
|
||||
ID: "agent-1",
|
||||
TenantID: 1,
|
||||
Config: types.CustomAgentConfig{
|
||||
QuestionSuggestions: &types.QuestionSuggestionConfig{
|
||||
Starters: types.StarterSuggestionConfig{
|
||||
Enabled: true,
|
||||
Mode: types.SuggestionModeCurated,
|
||||
Count: count,
|
||||
Items: items,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSuggestedQuestions_ExplicitLimitOverridesStarterCount is the
|
||||
// regression test for issue #2238: an explicit limit must be honored instead
|
||||
// of being clamped down to the agent's configured Starters.Count.
|
||||
func TestGetSuggestedQuestions_ExplicitLimitOverridesStarterCount(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
|
||||
items := []string{"q1", "q2", "q3", "q4", "q5", "q6", "q7", "q8", "q9", "q10"}
|
||||
svc := &customAgentService{
|
||||
repo: &suggestionLimitAgentRepo{agent: newCuratedStarterAgent(6, items)},
|
||||
}
|
||||
|
||||
got, err := svc.GetSuggestedQuestions(ctx, "agent-1", nil, nil, nil, 10)
|
||||
require.NoError(t, err)
|
||||
// Before the fix limit was clamped to Starters.Count (6); now the explicit
|
||||
// request of 10 is honored.
|
||||
assert.Len(t, got, 10)
|
||||
}
|
||||
|
||||
// TestGetSuggestedQuestions_SmallerLimitStillHonored guards the other
|
||||
// direction: a limit below Starters.Count must also take effect.
|
||||
func TestGetSuggestedQuestions_SmallerLimitStillHonored(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
|
||||
items := []string{"q1", "q2", "q3", "q4", "q5", "q6"}
|
||||
svc := &customAgentService{
|
||||
repo: &suggestionLimitAgentRepo{agent: newCuratedStarterAgent(6, items)},
|
||||
}
|
||||
|
||||
got, err := svc.GetSuggestedQuestions(ctx, "agent-1", nil, nil, nil, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, 2)
|
||||
}
|
||||
|
||||
// TestGetSuggestedQuestions_UnspecifiedLimitFallsBackToStarterCount confirms
|
||||
// that omitting the limit (<= 0) still uses the agent's configured count.
|
||||
func TestGetSuggestedQuestions_UnspecifiedLimitFallsBackToStarterCount(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
|
||||
items := []string{"q1", "q2", "q3", "q4", "q5", "q6", "q7", "q8"}
|
||||
svc := &customAgentService{
|
||||
repo: &suggestionLimitAgentRepo{agent: newCuratedStarterAgent(4, items)},
|
||||
}
|
||||
|
||||
got, err := svc.GetSuggestedQuestions(ctx, "agent-1", nil, nil, nil, 0)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, 4)
|
||||
}
|
||||
|
||||
// TestGetSuggestedQuestions_LimitBoundedByMax ensures an oversized limit is
|
||||
// capped so it cannot inflate the downstream candidate pool without bound.
|
||||
func TestGetSuggestedQuestions_LimitBoundedByMax(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(1))
|
||||
items := make([]string, 0, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
items = append(items, "q"+string(rune('A'+i%26))+string(rune('0'+i/26)))
|
||||
}
|
||||
svc := &customAgentService{
|
||||
repo: &suggestionLimitAgentRepo{agent: newCuratedStarterAgent(6, items)},
|
||||
}
|
||||
|
||||
got, err := svc.GetSuggestedQuestions(ctx, "agent-1", nil, nil, nil, 999)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, suggestionMaxLimit)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type suggestionTagRepo struct {
|
||||
interfaces.KnowledgeTagRepository
|
||||
tagsByTenant map[uint64][]*types.KnowledgeTag
|
||||
}
|
||||
|
||||
func (r *suggestionTagRepo) GetByIDs(_ context.Context, tenantID uint64, ids []string) ([]*types.KnowledgeTag, error) {
|
||||
wanted := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
wanted[id] = true
|
||||
}
|
||||
var result []*types.KnowledgeTag
|
||||
for _, tag := range r.tagsByTenant[tenantID] {
|
||||
if tag != nil && wanted[tag.ID] {
|
||||
result = append(result, tag)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type suggestionKnowledgeRepo struct {
|
||||
interfaces.KnowledgeRepository
|
||||
idsByTenantAndKB map[uint64]map[string][]string
|
||||
}
|
||||
|
||||
func (r *suggestionKnowledgeRepo) ListIDsByTagIDs(
|
||||
_ context.Context,
|
||||
tenantID uint64,
|
||||
kbID string,
|
||||
_ []string,
|
||||
) ([]string, error) {
|
||||
return append([]string(nil), r.idsByTenantAndKB[tenantID][kbID]...), nil
|
||||
}
|
||||
|
||||
type suggestionKBService struct {
|
||||
interfaces.KnowledgeBaseService
|
||||
kbs map[string]*types.KnowledgeBase
|
||||
}
|
||||
|
||||
func (s *suggestionKBService) GetKnowledgeBasesByIDsOnly(
|
||||
_ context.Context,
|
||||
ids []string,
|
||||
) ([]*types.KnowledgeBase, error) {
|
||||
result := make([]*types.KnowledgeBase, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if kb := s.kbs[id]; kb != nil {
|
||||
result = append(result, kb)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type suggestionKBShareService struct {
|
||||
interfaces.KBShareService
|
||||
allowed map[string]bool
|
||||
}
|
||||
|
||||
func (s *suggestionKBShareService) HasTenantKBPermission(
|
||||
_ context.Context,
|
||||
kbID string,
|
||||
_ uint64,
|
||||
_ types.TenantRole,
|
||||
_ types.OrgMemberRole,
|
||||
) (bool, error) {
|
||||
return s.allowed[kbID], nil
|
||||
}
|
||||
|
||||
func TestResolveSuggestionTagScopes_UsesSourceTenantForSharedKB(t *testing.T) {
|
||||
const (
|
||||
callerTenant = uint64(1)
|
||||
sourceTenant = uint64(2)
|
||||
kbID = "shared-kb"
|
||||
tagID = "shared-tag"
|
||||
)
|
||||
svc := &customAgentService{
|
||||
tagRepo: &suggestionTagRepo{tagsByTenant: map[uint64][]*types.KnowledgeTag{
|
||||
sourceTenant: {{ID: tagID, TenantID: sourceTenant, KnowledgeBaseID: kbID}},
|
||||
}},
|
||||
knowledgeRepo: &suggestionKnowledgeRepo{idsByTenantAndKB: map[uint64]map[string][]string{
|
||||
sourceTenant: {kbID: {"doc-in-tag"}},
|
||||
}},
|
||||
kbService: &suggestionKBService{kbs: map[string]*types.KnowledgeBase{
|
||||
kbID: {ID: kbID, TenantID: sourceTenant},
|
||||
}},
|
||||
kbShareService: &suggestionKBShareService{allowed: map[string]bool{kbID: true}},
|
||||
}
|
||||
|
||||
resolved, err := svc.resolveSuggestionTagScopes(
|
||||
context.Background(),
|
||||
callerTenant,
|
||||
[]types.TagScope{{KnowledgeBaseID: kbID, TagIDs: []string{tagID}}},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{kbID}, resolved.KnowledgeBaseIDs)
|
||||
assert.Equal(t, []string{"doc-in-tag"}, resolved.KnowledgeIDs)
|
||||
assert.Equal(t, []string{tagID}, resolved.TagIDsByTenant[sourceTenant])
|
||||
assert.Empty(t, resolved.TagIDsByTenant[callerTenant])
|
||||
}
|
||||
|
||||
func TestMergeHybridStarterSuggestions_ReservesKnowledgeSlots(t *testing.T) {
|
||||
curated := []types.SuggestedQuestion{
|
||||
{Question: "curated 1", Source: "agent_config"},
|
||||
{Question: "curated 2", Source: "agent_config"},
|
||||
{Question: "curated 3", Source: "agent_config"},
|
||||
{Question: "curated 4", Source: "agent_config"},
|
||||
{Question: "curated 5", Source: "agent_config"},
|
||||
{Question: "curated 6", Source: "agent_config"},
|
||||
}
|
||||
knowledge := []types.SuggestedQuestion{
|
||||
{Question: "knowledge 1", Source: "document"},
|
||||
{Question: "knowledge 2", Source: "faq"},
|
||||
{Question: "knowledge 3", Source: "document"},
|
||||
}
|
||||
|
||||
got := mergeHybridStarterSuggestions(curated, knowledge, 6)
|
||||
require.Len(t, got, 6)
|
||||
assert.Equal(t, []string{
|
||||
"curated 1", "curated 2", "curated 3", "curated 4", "knowledge 1", "knowledge 2",
|
||||
}, []string{got[0].Question, got[1].Question, got[2].Question, got[3].Question, got[4].Question, got[5].Question})
|
||||
}
|
||||
|
||||
func TestMergeHybridStarterSuggestions_BackfillsWhenKnowledgeIsEmpty(t *testing.T) {
|
||||
curated := []types.SuggestedQuestion{
|
||||
{Question: "curated 1"}, {Question: "curated 2"}, {Question: "curated 3"},
|
||||
}
|
||||
got := mergeHybridStarterSuggestions(curated, nil, 3)
|
||||
require.Len(t, got, 3)
|
||||
assert.Equal(t, []string{"curated 1", "curated 2", "curated 3"}, []string{
|
||||
got[0].Question, got[1].Question, got[2].Question,
|
||||
})
|
||||
}
|
||||
|
||||
func TestExcludeSuggestionStrings_TagScopeOverridesSameKnowledgeBase(t *testing.T) {
|
||||
got := excludeSuggestionStrings([]string{"kb-with-tag", "kb-explicit"}, []string{"kb-with-tag"})
|
||||
assert.Equal(t, []string{"kb-explicit"}, got)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/parquet-go/parquet-go"
|
||||
)
|
||||
|
||||
// DatasetService provides operations for working with datasets
|
||||
type DatasetService struct{}
|
||||
|
||||
// NewDatasetService creates a new DatasetService instance
|
||||
func NewDatasetService() interfaces.DatasetService {
|
||||
return &DatasetService{}
|
||||
}
|
||||
|
||||
// TextInfo represents text data with ID in parquet format
|
||||
type TextInfo struct {
|
||||
ID int64 `parquet:"id"` // Unique identifier
|
||||
Text string `parquet:"text"` // Text content
|
||||
}
|
||||
|
||||
// RelsInfo represents question-passage relations in parquet format
|
||||
type RelsInfo struct {
|
||||
QID int64 `parquet:"qid"` // Question ID
|
||||
PID int64 `parquet:"pid"` // Passage ID
|
||||
}
|
||||
|
||||
// QaInfo represents question-answer relations in parquet format
|
||||
type QaInfo struct {
|
||||
QID int64 `parquet:"qid"` // Question ID
|
||||
AID int64 `parquet:"aid"` // Answer ID
|
||||
}
|
||||
|
||||
// GetDatasetByID retrieves QA pairs from dataset by ID
|
||||
func (d *DatasetService) GetDatasetByID(ctx context.Context, datasetID string) ([]*types.QAPair, error) {
|
||||
logger.Info(ctx, "Start getting dataset by ID")
|
||||
logger.Infof(ctx, "Getting dataset with ID: %s", datasetID)
|
||||
|
||||
dataset := DefaultDataset()
|
||||
dataset.PrintStats(ctx)
|
||||
qaPairs := dataset.Iterate()
|
||||
|
||||
logger.Infof(ctx, "Retrieved %d QA pairs from dataset", len(qaPairs))
|
||||
return qaPairs, nil
|
||||
}
|
||||
|
||||
// DefaultDataset loads and initializes the default dataset from parquet files
|
||||
func DefaultDataset() dataset {
|
||||
datasetDir := "./dataset/samples"
|
||||
queries, err := loadParquet[TextInfo](fmt.Sprintf("%s/queries.parquet", datasetDir))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
corpus, err := loadParquet[TextInfo](fmt.Sprintf("%s/corpus.parquet", datasetDir))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
answers, err := loadParquet[TextInfo](fmt.Sprintf("%s/answers.parquet", datasetDir))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
qrels, err := loadParquet[RelsInfo](fmt.Sprintf("%s/qrels.parquet", datasetDir))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
qas, err := loadParquet[QaInfo](fmt.Sprintf("%s/qas.parquet", datasetDir))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
res := dataset{
|
||||
queries: make(map[int64]string), // qid -> question text
|
||||
corpus: make(map[int64]string), // pid -> passage text
|
||||
answers: make(map[int64]string), // aid -> answer text
|
||||
qrels: make(map[int64][]int64), // qid -> list of pid
|
||||
qas: make(map[int64]int64), // qid -> aid
|
||||
}
|
||||
for _, qi := range queries {
|
||||
res.queries[qi.ID] = qi.Text
|
||||
}
|
||||
for _, ci := range corpus {
|
||||
res.corpus[ci.ID] = ci.Text
|
||||
}
|
||||
for _, ai := range answers {
|
||||
res.answers[ai.ID] = ai.Text
|
||||
}
|
||||
for _, ri := range qrels {
|
||||
res.qrels[ri.QID] = append(res.qrels[ri.QID], ri.PID)
|
||||
}
|
||||
for _, qi := range qas {
|
||||
res.qas[qi.QID] = qi.AID
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// dataset represents the in-memory dataset structure
|
||||
type dataset struct {
|
||||
queries map[int64]string // qid -> question text
|
||||
corpus map[int64]string // pid -> passage text
|
||||
answers map[int64]string // aid -> answer text
|
||||
qrels map[int64][]int64 // qid -> list of related pids
|
||||
qas map[int64]int64 // qid -> aid
|
||||
}
|
||||
|
||||
// Iterate generates QA pairs from the dataset
|
||||
func (d *dataset) Iterate() []*types.QAPair {
|
||||
var pairs []*types.QAPair
|
||||
|
||||
for qid, question := range d.queries {
|
||||
// Get answer info
|
||||
aid, hasAnswer := d.qas[qid]
|
||||
answer := ""
|
||||
if hasAnswer {
|
||||
answer = d.answers[aid]
|
||||
}
|
||||
|
||||
// Get related passages
|
||||
pids := d.qrels[qid]
|
||||
var pidStr []int
|
||||
for _, pid := range pids {
|
||||
pidStr = append(pidStr, int(pid))
|
||||
}
|
||||
var passages []string
|
||||
for _, pid := range pids {
|
||||
passages = append(passages, d.corpus[pid])
|
||||
}
|
||||
|
||||
pairs = append(pairs, &types.QAPair{
|
||||
QID: int(qid),
|
||||
Question: question,
|
||||
PIDs: pidStr,
|
||||
Passages: passages,
|
||||
AID: int(aid),
|
||||
Answer: answer,
|
||||
})
|
||||
}
|
||||
|
||||
return pairs
|
||||
}
|
||||
|
||||
// GetContextForQID retrieves context passages for a given question ID
|
||||
func (d *dataset) GetContextForQID(qid int64) ([]string, error) {
|
||||
pids, ok := d.qrels[qid]
|
||||
if !ok {
|
||||
return nil, errors.New("question ID not found")
|
||||
}
|
||||
|
||||
var contextParts []string
|
||||
for _, pid := range pids {
|
||||
if text, exists := d.corpus[pid]; exists {
|
||||
contextParts = append(contextParts, text)
|
||||
}
|
||||
}
|
||||
|
||||
return contextParts, nil
|
||||
}
|
||||
|
||||
// PrintStats prints dataset statistics to the logger
|
||||
func (d *dataset) PrintStats(ctx context.Context) {
|
||||
logger.Infof(ctx, "QA System Statistics:")
|
||||
logger.Infof(ctx, "- Total queries: %d", len(d.queries))
|
||||
logger.Infof(ctx, "- Total corpus passages: %d", len(d.corpus))
|
||||
logger.Infof(ctx, "- Total answers: %d", len(d.answers))
|
||||
|
||||
// Calculate average passages per query
|
||||
totalRelations := 0
|
||||
for _, pids := range d.qrels {
|
||||
totalRelations += len(pids)
|
||||
}
|
||||
avgPassages := float64(totalRelations) / float64(len(d.qrels))
|
||||
logger.Infof(ctx, "- Average passages per query: %.2f", avgPassages)
|
||||
|
||||
// Calculate coverage
|
||||
coveredQueries := len(d.qas)
|
||||
coverage := float64(coveredQueries) / float64(len(d.queries)) * 100
|
||||
logger.Infof(ctx, "- Answer coverage: %.2f%% (%d/%d)", coverage, coveredQueries, len(d.queries))
|
||||
}
|
||||
|
||||
// PrintRandomQA prints a random question with its related passages and answer
|
||||
func (d *dataset) PrintRandomQA() error {
|
||||
// Get a random qid
|
||||
var qid int64
|
||||
for k := range d.qas {
|
||||
qid = k
|
||||
break
|
||||
}
|
||||
if qid == 0 {
|
||||
return errors.New("no questions available")
|
||||
}
|
||||
|
||||
// Get question text
|
||||
question, ok := d.queries[qid]
|
||||
if !ok {
|
||||
return fmt.Errorf("question %d not found", qid)
|
||||
}
|
||||
|
||||
// Get answer info
|
||||
aid, ok := d.qas[qid]
|
||||
if !ok {
|
||||
return fmt.Errorf("answer for question %d not found", qid)
|
||||
}
|
||||
answer, ok := d.answers[aid]
|
||||
if !ok {
|
||||
return fmt.Errorf("answer %d not found", aid)
|
||||
}
|
||||
|
||||
// Print formatted QA
|
||||
fmt.Println("===== Random QA =====")
|
||||
fmt.Printf("QID: %d\n", qid)
|
||||
fmt.Printf("Question: %s\n", question)
|
||||
|
||||
// Print passages if available
|
||||
if pids, exists := d.qrels[qid]; exists && len(pids) > 0 {
|
||||
fmt.Println("\nRelated passages:")
|
||||
for i, pid := range pids {
|
||||
if text, exists := d.corpus[pid]; exists {
|
||||
fmt.Printf("\nPassage %d (PID: %d):\n%s\n", i+1, pid, text)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println("\nNo related passages found")
|
||||
}
|
||||
|
||||
// Print answer
|
||||
fmt.Printf("\nAnswer (AID: %d):\n%s\n", aid, answer)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadParquet loads data from parquet file into specified type
|
||||
func loadParquet[T any](filePath string) ([]T, error) {
|
||||
rows, err := parquet.ReadFile[T](filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/application/repository"
|
||||
"github.com/Tencent/WeKnora/internal/datasource"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type sqliteDataSourceDeleteFixture struct {
|
||||
db *gorm.DB
|
||||
dsRepo interfaces.DataSourceRepository
|
||||
syncLogRepo interfaces.SyncLogRepository
|
||||
scheduler *datasource.Scheduler
|
||||
ds *types.DataSource
|
||||
pendingLog *types.SyncLog
|
||||
runningLog *types.SyncLog
|
||||
}
|
||||
|
||||
func newSQLiteDataSourceDeleteFixture(t *testing.T) *sqliteDataSourceDeleteFixture {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "weknora.db")), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&types.DataSource{}, &types.SyncLog{}))
|
||||
|
||||
dsRepo := repository.NewDataSourceRepository(db)
|
||||
syncLogRepo := repository.NewSyncLogRepository(db)
|
||||
ds := &types.DataSource{
|
||||
ID: "ds-sqlite-delete",
|
||||
TenantID: 1,
|
||||
KnowledgeBaseID: "kb-sqlite-delete",
|
||||
Name: "SQLite delete",
|
||||
Type: types.ConnectorTypeFeishu,
|
||||
Status: types.DataSourceStatusActive,
|
||||
SyncSchedule: "0 0 * * * *",
|
||||
}
|
||||
pendingLog := &types.SyncLog{
|
||||
ID: "log-pending",
|
||||
DataSourceID: ds.ID,
|
||||
TenantID: ds.TenantID,
|
||||
Status: "pending",
|
||||
}
|
||||
runningLog := &types.SyncLog{
|
||||
ID: "log-running",
|
||||
DataSourceID: ds.ID,
|
||||
TenantID: ds.TenantID,
|
||||
Status: types.SyncLogStatusRunning,
|
||||
}
|
||||
require.NoError(t, dsRepo.Create(context.Background(), ds))
|
||||
require.NoError(t, syncLogRepo.Create(context.Background(), pendingLog))
|
||||
require.NoError(t, syncLogRepo.Create(context.Background(), runningLog))
|
||||
|
||||
scheduler := datasource.NewScheduler(dsRepo, syncLogRepo, kbDeleteTaskEnqueuer{})
|
||||
require.NoError(t, scheduler.AddOrUpdate(ds))
|
||||
require.Equal(t, 1, scheduler.EntryCount())
|
||||
|
||||
return &sqliteDataSourceDeleteFixture{
|
||||
db: db,
|
||||
dsRepo: dsRepo,
|
||||
syncLogRepo: syncLogRepo,
|
||||
scheduler: scheduler,
|
||||
ds: ds,
|
||||
pendingLog: pendingLog,
|
||||
runningLog: runningLog,
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataSourceServiceDeleteSQLiteCleansUpAfterSoftDelete(t *testing.T) {
|
||||
fixture := newSQLiteDataSourceDeleteFixture(t)
|
||||
svc := &DataSourceService{
|
||||
dsRepo: fixture.dsRepo,
|
||||
syncLogRepo: fixture.syncLogRepo,
|
||||
scheduler: fixture.scheduler,
|
||||
}
|
||||
|
||||
require.NoError(t, svc.DeleteDataSource(context.Background(), fixture.ds.ID))
|
||||
|
||||
_, err := fixture.dsRepo.FindByID(context.Background(), fixture.ds.ID)
|
||||
require.EqualError(t, err, "data source not found")
|
||||
assert.Equal(t, 0, fixture.scheduler.EntryCount())
|
||||
|
||||
for _, logID := range []string{fixture.pendingLog.ID, fixture.runningLog.ID} {
|
||||
log, err := fixture.syncLogRepo.FindByID(context.Background(), logID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, types.SyncLogStatusCanceled, log.Status)
|
||||
require.NotNil(t, log.FinishedAt)
|
||||
assert.Equal(t, "data source deleted", log.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataSourceServiceDeleteKeepsCleanupStateWhenSoftDeleteFails(t *testing.T) {
|
||||
fixture := newSQLiteDataSourceDeleteFixture(t)
|
||||
require.NoError(t, fixture.db.Exec(`
|
||||
CREATE TRIGGER fail_datasource_soft_delete
|
||||
BEFORE UPDATE OF deleted_at ON data_sources
|
||||
WHEN NEW.id = 'ds-sqlite-delete'
|
||||
BEGIN
|
||||
SELECT RAISE(FAIL, 'forced soft delete failure');
|
||||
END;
|
||||
`).Error)
|
||||
svc := &DataSourceService{
|
||||
dsRepo: fixture.dsRepo,
|
||||
syncLogRepo: fixture.syncLogRepo,
|
||||
scheduler: fixture.scheduler,
|
||||
}
|
||||
|
||||
err := svc.DeleteDataSource(context.Background(), fixture.ds.ID)
|
||||
require.ErrorContains(t, err, "forced soft delete failure")
|
||||
|
||||
found, err := fixture.dsRepo.FindByID(context.Background(), fixture.ds.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fixture.ds.ID, found.ID)
|
||||
assert.Equal(t, 1, fixture.scheduler.EntryCount())
|
||||
|
||||
pending, err := fixture.syncLogRepo.FindByID(context.Background(), fixture.pendingLog.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pending", pending.Status)
|
||||
running, err := fixture.syncLogRepo.FindByID(context.Background(), fixture.runningLog.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, types.SyncLogStatusRunning, running.Status)
|
||||
}
|
||||
|
||||
func TestDeleteKnowledgeBaseCleansUpSQLiteDataSources(t *testing.T) {
|
||||
fixture := newSQLiteDataSourceDeleteFixture(t)
|
||||
kbRepo := &kbDeleteKBRepo{fakeKBRepo: *newFakeKBRepo()}
|
||||
kbRepo.rows[fixture.ds.KnowledgeBaseID] = &types.KnowledgeBase{
|
||||
ID: fixture.ds.KnowledgeBaseID,
|
||||
TenantID: fixture.ds.TenantID,
|
||||
Name: "SQLite delete",
|
||||
}
|
||||
svc := &knowledgeBaseService{
|
||||
repo: kbRepo,
|
||||
asynqClient: kbDeleteTaskEnqueuer{},
|
||||
dsRepo: fixture.dsRepo,
|
||||
syncLogRepo: fixture.syncLogRepo,
|
||||
dsScheduler: fixture.scheduler,
|
||||
}
|
||||
|
||||
err := svc.DeleteKnowledgeBase(
|
||||
ctxWithTenantStorage(fixture.ds.TenantID, "local"),
|
||||
fixture.ds.KnowledgeBaseID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fixture.ds.KnowledgeBaseID, kbRepo.deletedID)
|
||||
|
||||
_, err = fixture.dsRepo.FindByID(context.Background(), fixture.ds.ID)
|
||||
require.EqualError(t, err, "data source not found")
|
||||
var deleted types.DataSource
|
||||
require.NoError(t, fixture.db.Unscoped().First(&deleted, "id = ?", fixture.ds.ID).Error)
|
||||
assert.True(t, deleted.DeletedAt.Valid)
|
||||
assert.Equal(t, 0, fixture.scheduler.EntryCount())
|
||||
|
||||
for _, logID := range []string{fixture.pendingLog.ID, fixture.runningLog.ID} {
|
||||
log, err := fixture.syncLogRepo.FindByID(context.Background(), logID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, types.SyncLogStatusCanceled, log.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// A sync that fails thousands of documents must not accumulate an unbounded
|
||||
// result.Errors slice: that slice is persisted as jsonb and shipped in every
|
||||
// sync-log list response, so an uncapped list means multi-MB rows and payloads.
|
||||
// The accurate count lives in result.Failed (a bounded int), so Errors only
|
||||
// needs to retain a sample for display (Tencent/WeKnora#2136 / #1262).
|
||||
func TestApplyFetchedItem_CapsErrorSampleButKeepsAccurateFailedCount(t *testing.T) {
|
||||
s := &DataSourceService{}
|
||||
ds := &types.DataSource{}
|
||||
result := &types.SyncResult{}
|
||||
ctx := context.Background()
|
||||
|
||||
const failures = 5000
|
||||
for i := 0; i < failures; i++ {
|
||||
s.applyFetchedItem(ctx, ds, &types.FetchedItem{
|
||||
ExternalID: "node",
|
||||
Title: "doc",
|
||||
Metadata: map[string]string{"error": "export failed: rate limited"},
|
||||
}, nil, result)
|
||||
}
|
||||
|
||||
assert.Equal(t, failures, result.Failed,
|
||||
"Failed must count every failure regardless of the error-sample cap")
|
||||
assert.LessOrEqual(t, len(result.Errors), maxSyncResultErrors,
|
||||
"Errors sample must be capped so the persisted jsonb stays bounded")
|
||||
assert.Equal(t, maxSyncResultErrors, len(result.Errors),
|
||||
"with more failures than the cap, the sample should be exactly the cap")
|
||||
}
|
||||
|
||||
// The user-facing sample must be a structured SyncItemError carrying the
|
||||
// connector's stable i18n code + params (so the frontend localises it), the
|
||||
// title as a separate field, and no raw API body/log_id — that stays in logs.
|
||||
func TestApplyFetchedItem_ProducesLocalisableStructuredError(t *testing.T) {
|
||||
s := &DataSourceService{}
|
||||
ds := &types.DataSource{}
|
||||
result := &types.SyncResult{}
|
||||
|
||||
s.applyFetchedItem(context.Background(), ds, &types.FetchedItem{
|
||||
ExternalID: "nt1",
|
||||
Title: "季度报告",
|
||||
Metadata: map[string]string{
|
||||
// raw, for logs only
|
||||
"error": `export 季度报告 (docx): feishu api error: status=500 body={"code":1663,"error":{"log_id":"20260"}}`,
|
||||
// classification, for a localisable UI sample
|
||||
"error_reason_code": "feishu_api_error",
|
||||
"error_reason_code_value": "1663",
|
||||
"error_reason": "Feishu API error (code=1663); will retry automatically",
|
||||
},
|
||||
}, nil, result)
|
||||
|
||||
assert.Equal(t, 1, len(result.Errors))
|
||||
got := result.Errors[0]
|
||||
assert.Equal(t, "季度报告", got.Title, "title is a separate field, not embedded in the message")
|
||||
assert.Equal(t, "feishu_api_error", got.Code, "stable i18n code drives frontend localisation")
|
||||
assert.Equal(t, "1663", got.Params["code"], "feishu error code is passed as an interpolation param")
|
||||
assert.NotContains(t, got.Message, "body=", "raw API body must not reach the UI sample")
|
||||
assert.NotContains(t, got.Message, "log_id", "raw log_id must not reach the UI sample")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user