From 84f65a809f7cc03fdaf60d25f04c145acc41aa80 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Fri, 24 Jul 2026 15:53:49 +0800 Subject: [PATCH] fix(agent): ensure wiki summary slugs are preserved during citation compaction This update addresses the issue where wiki summary-page slugs (summary/) 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. --- internal/agent/engine_test.go | 55 ++++++++++++++++++++++++++ internal/agent/think.go | 11 +++++- internal/llmreference/registry.go | 37 ++++++++++++++++- internal/llmreference/registry_test.go | 24 +++++++++++ scripts/dev.sh | 26 ++++++------ 5 files changed, 140 insertions(+), 13 deletions(-) diff --git a/internal/agent/engine_test.go b/internal/agent/engine_test.go index b60879df..6bb86958 100644 --- a/internal/agent/engine_test.go +++ b/internal/agent/engine_test.go @@ -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/` → `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 (…) would have registered it upstream. + require.Equal(t, "d1", engine.sourceRefs.RegisterDocument(knowledgeID)) + + toolMsg := chat.Message{ + Role: "tool", + Content: `[[` + summarySlug + `|Weknora 试错记录.md - Summary]]`, + } + 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 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/) +// 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)`) refCandidateRE = regexp.MustCompile(`(?is)]*(?:>|$)`) diff --git a/internal/llmreference/registry_test.go b/internal/llmreference/registry_test.go index 8e4e3429..6b74f0cd 100644 --- a/internal/llmreference/registry_test.go +++ b/internal/llmreference/registry_test.go @@ -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 label. + in := "" + kbID + "\n" + + "[[summary/" + knowledgeID + "|Doc - Summary]]\n" + + "" + knowledgeID + "" + + 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, "d1") + require.Contains(t, got, "b1") +} + func TestRegistrySuppressesSourceCitationsWhenDisabled(t *testing.T) { registry := NewRegistry(false) registry.RegisterChunk(ChunkReference{ChunkID: "chunk-1", DocumentTitle: "Doc"}) diff --git a/scripts/dev.sh b/scripts/dev.sh index 4ad48d84..315b3161 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -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 }