fix(agent): ensure wiki summary slugs are preserved during citation compaction
This update addresses the issue where wiki summary-page slugs (summary/<knowledgeID>) were being mangled during the citation compaction process, leading to dead links. The changes include: - Reordering the message encoding process to ensure resource references are aliased before source references, preventing the UUID from being replaced in the summary slug. - Implementing a new function to selectively replace document IDs while preserving the integrity of summary slugs. - Adding unit tests to verify that summary slugs remain intact and are not altered during compaction. This fix enhances the reliability of cross-links in the wiki system and prevents potential dead links from being generated.
This commit is contained in:
@@ -70,6 +70,61 @@ func TestStreamLLMResourceAliasesRoundTrip(t *testing.T) {
|
||||
require.Equal(t, "source=res://0001", model.calls[0][0].Content)
|
||||
}
|
||||
|
||||
// TestStreamLLMSummarySlugSurvivesDocumentCompaction is the regression guard for
|
||||
// the mangled `summary/<uuid>` → `summary/d1` bug. A wiki summary-page slug
|
||||
// embeds a document's UUID, and sourceRefs.EncodeMessages compacts that same
|
||||
// UUID into a citation alias (d1) via a blind substring replace. resourceRefs
|
||||
// must alias the slug to a res:// token BEFORE citation compaction runs;
|
||||
// otherwise the model is shown a mangled slug it then copies into a wiki tool
|
||||
// call that 404s. See the EncodeMessages ordering in streamLLMToEventBus.
|
||||
func TestStreamLLMSummarySlugSurvivesDocumentCompaction(t *testing.T) {
|
||||
const knowledgeID = "07a20bb1-a662-47cf-9929-06fb5d5b5b5e"
|
||||
const summarySlug = "summary/" + knowledgeID
|
||||
|
||||
// The model copies the protected token it saw back into a wiki_read call.
|
||||
model := &mockChat{responses: []mockResponse{{chunks: []types.StreamResponse{
|
||||
{
|
||||
ResponseType: types.ResponseTypeAnswer,
|
||||
Content: "reading the summary",
|
||||
ToolCalls: []types.LLMToolCall{{
|
||||
Type: "function",
|
||||
Function: types.FunctionCall{
|
||||
Name: "wiki_read_page",
|
||||
Arguments: `{"slugs":["res://0001"]}`,
|
||||
},
|
||||
}},
|
||||
Done: true,
|
||||
FinishReason: "tool_calls",
|
||||
},
|
||||
}}}}
|
||||
|
||||
engine := newTestEngine(t, model)
|
||||
// The document UUID is registered as citation alias d1, exactly as the RAG
|
||||
// context (<document id="d1">…) would have registered it upstream.
|
||||
require.Equal(t, "d1", engine.sourceRefs.RegisterDocument(knowledgeID))
|
||||
|
||||
toolMsg := chat.Message{
|
||||
Role: "tool",
|
||||
Content: `<link>[[` + summarySlug + `|Weknora 试错记录.md - Summary]]</link>`,
|
||||
}
|
||||
result, err := engine.streamLLMToEventBus(context.Background(),
|
||||
[]chat.Message{toolMsg}, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// What the model actually saw must NOT contain the mangled slug; the UUID
|
||||
// must have been aliased to a res:// token before citation compaction ran.
|
||||
require.Len(t, model.calls, 1)
|
||||
sent := model.calls[0][0].Content
|
||||
require.NotContains(t, sent, "summary/d1",
|
||||
"summary slug was clobbered by document-id compaction (encode ordering regressed)")
|
||||
require.Contains(t, sent, "res://", "summary slug must be protected as a res:// token")
|
||||
|
||||
// The model's tool call echoing the token must decode back to the real slug.
|
||||
require.Len(t, result.ToolCalls, 1)
|
||||
require.Contains(t, result.ToolCalls[0].Function.Arguments, summarySlug)
|
||||
require.NotContains(t, result.ToolCalls[0].Function.Arguments, "res://")
|
||||
}
|
||||
|
||||
func TestStreamLLMChunkReferenceExpandsBeforeEmission(t *testing.T) {
|
||||
model := &mockChat{responses: []mockResponse{{chunks: []types.StreamResponse{
|
||||
{ResponseType: types.ResponseTypeAnswer, Content: `answer <ref id="`},
|
||||
|
||||
+10
-1
@@ -39,8 +39,17 @@ func (e *AgentEngine) streamLLMToEventBus(
|
||||
llmCtx, llmCancel := context.WithTimeout(ctx, e.getLLMCallTimeout())
|
||||
defer llmCancel()
|
||||
|
||||
messages = e.sourceRefs.EncodeMessages(messages)
|
||||
// Order matters. resourceRefs must alias durable resource handles FIRST,
|
||||
// because wiki summary-page slugs (summary/<knowledgeID>) embed a document's
|
||||
// UUID and sourceRefs.EncodeMessages compacts that same UUID into a document
|
||||
// citation alias (d1, d2, …) via a blind substring replace. Running
|
||||
// sourceRefs first mangled `[[summary/<uuid>|Title]]` into `[[summary/d1]]`
|
||||
// — an unrecoverable dead link the model then copied into wiki tool calls.
|
||||
// Encoding resourceRefs first turns the slug into a res:// token so the UUID
|
||||
// is gone before citation compaction runs; the two alias spaces (res://NNNN
|
||||
// vs d/c/b/w-N) are disjoint, so decode order is unaffected.
|
||||
messages = e.resourceRefs.EncodeMessages(messages)
|
||||
messages = e.sourceRefs.EncodeMessages(messages)
|
||||
prefixFingerprint := chat.PromptPrefixFingerprint(messages, opts)
|
||||
llmCtx = types.WithLLMCallMetadata(llmCtx, "agent_round", prefixFingerprint)
|
||||
stream, err := e.chatModel.ChatStream(llmCtx, messages, opts)
|
||||
|
||||
@@ -536,12 +536,47 @@ func (r *Registry) CompactKnownText(text string) string {
|
||||
sort.SliceStable(pairs, func(i, j int) bool { return len(pairs[i].real) > len(pairs[j].real) })
|
||||
for _, item := range pairs {
|
||||
if item.real != "" {
|
||||
text = strings.ReplaceAll(text, item.real, item.alias)
|
||||
text = replaceIDExceptWikiSummarySlug(text, item.real, item.alias)
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// wikiSummarySlugPrefix guards wiki summary-page slugs (summary/<knowledgeID>)
|
||||
// from citation compaction. A summary slug embeds a document's knowledge ID
|
||||
// verbatim, so a blind ReplaceAll of that ID into its citation alias (d1) would
|
||||
// mangle the slug into summary/d1 — an unrecoverable dead link the model then
|
||||
// copies into wiki tool calls. Only the slug-embedded occurrence is preserved;
|
||||
// the same document ID elsewhere in the text still compacts normally.
|
||||
const wikiSummarySlugPrefix = "summary/"
|
||||
|
||||
// replaceIDExceptWikiSummarySlug replaces every occurrence of real with alias,
|
||||
// except one that is immediately preceded by "summary/" (i.e. it is the
|
||||
// identifier segment of a wiki summary-page slug). Go's regexp package has no
|
||||
// lookbehind, so the scan is done manually.
|
||||
func replaceIDExceptWikiSummarySlug(text, real, alias string) string {
|
||||
if real == "" || !strings.Contains(text, real) {
|
||||
return text
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(text))
|
||||
for {
|
||||
i := strings.Index(text, real)
|
||||
if i < 0 {
|
||||
b.WriteString(text)
|
||||
break
|
||||
}
|
||||
b.WriteString(text[:i])
|
||||
if strings.HasSuffix(text[:i], wikiSummarySlugPrefix) {
|
||||
b.WriteString(real) // part of a summary slug — keep intact
|
||||
} else {
|
||||
b.WriteString(alias)
|
||||
}
|
||||
text = text[i+len(real):]
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var (
|
||||
refTagRE = regexp.MustCompile(`(?i)<ref\s+id\s*=\s*"([^"]+)"\s*/?>`)
|
||||
refCandidateRE = regexp.MustCompile(`(?is)<ref(?:\s|$)[^>]*(?:>|$)`)
|
||||
|
||||
@@ -32,6 +32,30 @@ func TestRegistryChunkAliasIsStableAndExpandsCanonicalCitation(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestCompactKnownTextPreservesWikiSummarySlug(t *testing.T) {
|
||||
const knowledgeID = "07a20bb1-a662-47cf-9929-06fb5d5b5b5e"
|
||||
const kbID = "250368ff-f5a2-4e9e-868a-07bc9b857c44"
|
||||
|
||||
registry := NewRegistry()
|
||||
require.Equal(t, "d1", registry.RegisterDocument(knowledgeID))
|
||||
require.Equal(t, "b1", registry.RegisterKnowledgeBase(kbID))
|
||||
|
||||
// A wiki_search result: the summary slug embeds the document UUID, and the
|
||||
// same UUID also appears as a standalone <knowledge_id> label.
|
||||
in := "<knowledge_base_id>" + kbID + "</knowledge_base_id>\n" +
|
||||
"<link>[[summary/" + knowledgeID + "|Doc - Summary]]</link>\n" +
|
||||
"<knowledge_id>" + knowledgeID + "</knowledge_id>"
|
||||
|
||||
got := registry.CompactKnownText(in)
|
||||
|
||||
// The slug-embedded UUID must survive verbatim — no summary/d1 mangling.
|
||||
require.Contains(t, got, "[[summary/"+knowledgeID+"|Doc - Summary]]")
|
||||
require.NotContains(t, got, "summary/d1")
|
||||
// The standalone label and the KB id still compact normally.
|
||||
require.Contains(t, got, "<knowledge_id>d1</knowledge_id>")
|
||||
require.Contains(t, got, "<knowledge_base_id>b1</knowledge_base_id>")
|
||||
}
|
||||
|
||||
func TestRegistrySuppressesSourceCitationsWhenDisabled(t *testing.T) {
|
||||
registry := NewRegistry(false)
|
||||
registry.RegisterChunk(ChunkReference{ChunkID: "chunk-1", DocumentTitle: "Doc"})
|
||||
|
||||
+15
-11
@@ -86,28 +86,32 @@ show_help() {
|
||||
}
|
||||
|
||||
# 加载 .env 与可选的 .env.local(后者覆盖前者)
|
||||
# 读取 .env 时去掉行尾的 \r,兼容 Windows 风格(CRLF)换行符,
|
||||
# 读取时去掉行尾 \r,兼容 Windows 风格(CRLF)换行符,
|
||||
# 否则 bash source 会把残留的 \r 当成命令导致 "...: $'\r': command not found"。
|
||||
_clean_env_file() {
|
||||
sed -e 's/\r$//' "$1"
|
||||
# 注意:不能用 source <(sed ...)——macOS 自带 Bash 3.2 对 process substitution
|
||||
# 的 source 不会把变量导入当前 shell;必须落到可 seek 的临时文件再 source。
|
||||
_source_env_file() {
|
||||
local src="$1"
|
||||
local tmp
|
||||
tmp="$(mktemp)" || return 1
|
||||
sed -e 's/\r$//' "$src" > "$tmp"
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "$tmp"
|
||||
set +a
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
load_env_files() {
|
||||
if [ -f ".env" ]; then
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source <(_clean_env_file .env)
|
||||
set +a
|
||||
_source_env_file .env || return 1
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ -f ".env.local" ]; then
|
||||
log_info "加载 .env.local 覆盖配置..."
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source <(_clean_env_file .env.local)
|
||||
set +a
|
||||
_source_env_file .env.local || return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user