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:
wizardchen
2026-07-24 15:53:49 +08:00
committed by lyingbug
parent 5d0326f987
commit 84f65a809f
5 changed files with 140 additions and 13 deletions
+55
View File
@@ -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
View File
@@ -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)
+36 -1
View File
@@ -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|$)[^>]*(?:>|$)`)
+24
View File
@@ -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
View File
@@ -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
}