Files
xinmi/internal/application/service/memory/recall_trace.go
T
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

198 lines
6.1 KiB
Go

package memory
import (
"context"
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
const recallQueryPreviewRunes = 500
// recallRankingTrace captures how situational items were ranked for one turn.
type recallRankingTrace struct {
LexicalHits int
VectorHits int
VectorSkipReason string
FusedCandidates int
Matched int
Mode string
}
// scopeDisableReason explains why memory is off for this request. Only called
// when enabledScope returned false.
func (s *Service) scopeDisableReason(ctx context.Context) string {
scope, err := ResolveScope(ctx)
if err != nil {
return "no_principal"
}
cfg := s.workspaceConfig(ctx, scope.TenantID)
if !cfg.MemoryEnabled() {
return "workspace_disabled"
}
if !types.MemoryAllowedForAgent(ctx) {
return "agent_disabled"
}
subject, err := s.repo.GetSubject(ctx, scope)
if err != nil {
return "subject_load_failed"
}
if subject != nil && !subject.Enabled {
return "user_disabled"
}
return "unknown"
}
// recallEmptyMeta explains why Recall produced no prompt.
func (s *Service) recallEmptyMeta(
scope interfaces.MemoryScope,
residentCount, candidateCount int,
rankTrace recallRankingTrace,
) map[string]interface{} {
return map[string]interface{}{
"outcome": "empty",
"reason": "no_injectable_memories",
"subject_id": scope.SubjectID,
"resident_count": residentCount,
"candidate_count": candidateCount,
"lexical_hits": rankTrace.LexicalHits,
"vector_hits": rankTrace.VectorHits,
"vector_skip": rankTrace.VectorSkipReason,
"ranking_mode": rankTrace.Mode,
"fused_candidates": rankTrace.FusedCandidates,
}
}
// splitResidentInterests separates interests from the rest of the resident set,
// which are treated differently: the others are unconditional, interests are
// capped.
func splitResidentInterests(items []*types.MemoryItem) (others, interests []*types.MemoryItem) {
for _, item := range items {
if item == nil {
continue
}
if item.Kind == types.MemoryKindInterest {
interests = append(interests, item)
continue
}
others = append(others, item)
}
return others, interests
}
// selectResidentInterests chooses which interests go into the resident block.
//
// It returns two lists because injecting and reporting are different questions.
// Everything in `selected` is injected, up to the cap. Only `relevant` — the
// ones the current question actually matches — is reported to the chat UI: an
// interest that is present merely because there was room is standing
// background, and listing it as "recalled for this answer" would fill the
// timeline with memories that had nothing to do with what was asked.
//
// Matching is lexical only. The semantic pass needs a query embedding, and
// spending a model round trip on a list this short, whose entries are topic
// labels that a related question usually names outright, is not worth adding
// to the front of every turn.
func selectResidentInterests(
query string, interests []*types.MemoryItem, maxItems int,
) (selected, relevant []*types.MemoryItem) {
if len(interests) == 0 || maxItems <= 0 {
return nil, nil
}
taken := make(map[int]struct{}, maxItems)
for _, index := range lexicalRanking(query, interests) {
if len(selected) >= maxItems {
break
}
taken[index] = struct{}{}
selected = append(selected, interests[index])
relevant = append(relevant, interests[index])
}
// Fill whatever room is left in repository order (importance, then
// recency), so a question that matches nothing still sees the subjects
// this person keeps coming back to.
for index, item := range interests {
if len(selected) >= maxItems {
break
}
if _, dup := taken[index]; dup {
continue
}
selected = append(selected, item)
}
return selected, relevant
}
// selectRecallWithTrace ranks candidates against the query and returns the
// best ones that fit in maxItems and runeBudget. The budgets are arguments
// rather than constants because the same fusion serves two callers with very
// different economics: a per-turn recall that must stay small, and an
// on-demand search the model paid a tool call for.
func (s *Service) selectRecallWithTrace(
ctx context.Context,
scope interfaces.MemoryScope,
cfg *types.MemoryConfig,
query string,
candidates []*types.MemoryItem,
maxItems int,
runeBudget int,
) ([]*types.MemoryItem, recallRankingTrace) {
trace := recallRankingTrace{}
if len(candidates) == 0 {
trace.Mode = "no_candidates"
return nil, trace
}
_, lexSpan := langfuse.GetManager().StartSpan(ctx, langfuse.SpanOptions{
Name: "memory.recall.lexical",
Input: map[string]interface{}{
"query": langfuse.TruncateRunes(query, recallQueryPreviewRunes),
"candidates": len(candidates),
},
})
lexical := lexicalRanking(query, candidates)
trace.LexicalHits = len(lexical)
lexSpan.Finish(map[string]interface{}{
"hits": trace.LexicalHits,
}, nil, nil)
vecCtx, vecSpan := langfuse.GetManager().StartSpan(ctx, langfuse.SpanOptions{
Name: "memory.recall.vector",
Input: map[string]interface{}{
"query": langfuse.TruncateRunes(query, recallQueryPreviewRunes),
"candidates": len(candidates),
},
Metadata: map[string]interface{}{
"vector_enabled": cfg != nil && cfg.VectorRecallEnabled(),
},
})
vector, vectorSkip := s.vectorRanking(vecCtx, scope, cfg, query, candidates)
trace.VectorHits = len(vector)
trace.VectorSkipReason = vectorSkip
vecOut := map[string]interface{}{
"hits": trace.VectorHits,
}
if vectorSkip != "" {
vecOut["skip_reason"] = vectorSkip
}
vecSpan.Finish(vecOut, nil, nil)
if len(vector) == 0 {
trace.Mode = "lexical_only"
matched := takeWithinBudget(lexical, candidates, maxItems, runeBudget)
trace.Matched = len(matched)
return matched, trace
}
if len(vector) > maxItems*2 {
vector = vector[:maxItems*2]
}
fused := fuseRankings(lexical, vector)
trace.FusedCandidates = len(fused)
trace.Mode = "hybrid"
matched := takeWithinBudget(fused, candidates, maxItems, runeBudget)
trace.Matched = len(matched)
return matched, trace
}