WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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
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
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user