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
128 lines
4.9 KiB
Go
128 lines
4.9 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
)
|
|
|
|
// searchCandidatePool bounds how many stored items one search ranks over.
|
|
//
|
|
// It matches the recall pool deliberately. The pool was never what made recall
|
|
// miss things — four hundred candidates is more than almost any subject holds
|
|
// — the output cap of five items was. Widening the pool here would buy little
|
|
// and would put a few thousand embedding reads on a path the user is waiting
|
|
// on.
|
|
const searchCandidatePool = 400
|
|
|
|
// MemoryAvailable reports whether this request may read memory at all.
|
|
//
|
|
// It is deliberately the same predicate SearchMemory itself applies, rather
|
|
// than a second reading of the three switches. A caller that decides whether
|
|
// to offer a memory feature and the code that answers when it is used must
|
|
// never be able to disagree about whether memory is on.
|
|
func (s *Service) MemoryAvailable(ctx context.Context) bool {
|
|
_, _, ok := s.enabledScope(ctx)
|
|
return ok
|
|
}
|
|
|
|
// SearchMemory ranks this user's stored memories against an arbitrary query.
|
|
//
|
|
// Recall runs once per turn, against the question the user opened with, and
|
|
// what it admits is capped hard: five situational items inside a 600-rune
|
|
// budget. Two things fall outside that. An agent loop that has spent ten
|
|
// iterations moving away from the opening question is now working on something
|
|
// that shares no wording with what recall was ranked against; and a subject
|
|
// with dozens of stored facts has most of them sitting below the cut with no
|
|
// way to reach them. Neither is fixable by enlarging the per-turn budget,
|
|
// because that budget is paid on every turn including the ones that need none
|
|
// of it.
|
|
//
|
|
// Only active items are searched. Superseded and archived memories stay out of
|
|
// reach on purpose: a statement that a newer one replaced is precisely what
|
|
// the supersede machinery exists to keep out of an answer, and surfacing it
|
|
// through a side door would undo that.
|
|
func (s *Service) SearchMemory(
|
|
ctx context.Context, query string, limit int,
|
|
) interfaces.MemorySearchResult {
|
|
query = strings.TrimSpace(query)
|
|
|
|
searchCtx, searchSpan := langfuse.GetManager().StartSpan(ctx, langfuse.SpanOptions{
|
|
Name: "memory.search",
|
|
Input: map[string]interface{}{
|
|
"query": langfuse.TruncateRunes(query, recallQueryPreviewRunes),
|
|
"limit": limit,
|
|
},
|
|
})
|
|
|
|
scope, cfg, ok := s.enabledScope(searchCtx)
|
|
if !ok {
|
|
reason := s.scopeDisableReason(searchCtx)
|
|
logger.Infof(searchCtx, "memory: search skipped (%s)", reason)
|
|
searchSpan.Finish(langfuse.SummarizeMemoryRecallOutput(map[string]interface{}{
|
|
"outcome": "disabled",
|
|
"reason": reason,
|
|
}, nil), nil, nil)
|
|
return interfaces.MemorySearchResult{}
|
|
}
|
|
|
|
// An empty query reaches here rather than short-circuiting above so that
|
|
// "memory is off" still wins over "you asked for nothing": the caller
|
|
// needs the disabled answer even when its own arguments were malformed.
|
|
if query == "" {
|
|
searchSpan.Finish(langfuse.SummarizeMemoryRecallOutput(map[string]interface{}{
|
|
"outcome": "empty",
|
|
"reason": "blank_query",
|
|
}, nil), nil, nil)
|
|
return interfaces.MemorySearchResult{Available: true}
|
|
}
|
|
|
|
if limit <= 0 {
|
|
limit = types.MemorySearchDefaultItems
|
|
}
|
|
if limit > types.MemorySearchMaxItems {
|
|
limit = types.MemorySearchMaxItems
|
|
}
|
|
|
|
candidates, err := s.repo.ListActiveByKinds(searchCtx, scope, types.MemoryKinds, searchCandidatePool)
|
|
if err != nil {
|
|
logger.Warnf(searchCtx, "memory: load search candidates failed: %v", err)
|
|
searchSpan.Finish(langfuse.SummarizeMemoryRecallOutput(map[string]interface{}{
|
|
"outcome": "error",
|
|
"error": err.Error(),
|
|
}, nil), nil, err)
|
|
return interfaces.MemorySearchResult{Available: true}
|
|
}
|
|
|
|
matched, rankTrace := s.selectRecallWithTrace(
|
|
searchCtx, scope, cfg, query, candidates, limit, types.MemorySearchRuneBudget)
|
|
|
|
// A searched memory was read by the model just as surely as an injected
|
|
// one, so it counts as used. Without this the items only reachable through
|
|
// search would look permanently unused and rank lowest when the capacity
|
|
// cap next decides what to archive.
|
|
s.touchAsync(searchCtx, scope, matched)
|
|
|
|
logger.Infof(searchCtx,
|
|
"memory: search done subject=%s candidates=%d matched=%d mode=%s",
|
|
scope.SubjectID, len(candidates), len(matched), rankTrace.Mode)
|
|
searchSpan.Finish(langfuse.SummarizeMemoryRecallOutput(map[string]interface{}{
|
|
"outcome": "ok",
|
|
"subject_id": scope.SubjectID,
|
|
"candidate_count": len(candidates),
|
|
"lexical_hits": rankTrace.LexicalHits,
|
|
"vector_hits": rankTrace.VectorHits,
|
|
"vector_skip": rankTrace.VectorSkipReason,
|
|
"ranking_mode": rankTrace.Mode,
|
|
"matched_count": len(matched),
|
|
}, matched), map[string]interface{}{
|
|
"tenant_id": scope.TenantID,
|
|
}, nil)
|
|
|
|
return interfaces.MemorySearchResult{Available: true, Items: matched}
|
|
}
|