528 lines
18 KiB
Go
528 lines
18 KiB
Go
package memory
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"strings"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/models/chat"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
// consolidateInterval is the minimum wait between whole-store reviews. This
|
|||
|
|
// is maintenance, not a feature the user is waiting on, and every run costs
|
|||
|
|
// a model call, so it is deliberately infrequent.
|
|||
|
|
consolidateInterval = 24 * time.Hour
|
|||
|
|
// forcedConsolidateInterval is the floor between two reviews one person
|
|||
|
|
// asks for. Short enough that a real retry — fix the model, press again —
|
|||
|
|
// is not blocked, long enough that the button cannot be scripted into a
|
|||
|
|
// stream of model calls.
|
|||
|
|
forcedConsolidateInterval = time.Minute
|
|||
|
|
// consolidateMinItems is the store size below which there is nothing worth
|
|||
|
|
// reviewing: a handful of memories cannot have drifted into contradiction.
|
|||
|
|
consolidateMinItems = 6
|
|||
|
|
// consolidateMaxClusters bounds the model calls one review makes. A person
|
|||
|
|
// who asked for the review is waiting on it and can afford a few more.
|
|||
|
|
consolidateMaxClusters = 3
|
|||
|
|
forcedMaxClusters = 8
|
|||
|
|
// consolidateMinOverlap is how much wording two memories must share before
|
|||
|
|
// the daily pass spends a model call comparing them. Unattended runs are
|
|||
|
|
// budget-limited rather than careful: being wrong here costs nothing but a
|
|||
|
|
// call, because the model still decides.
|
|||
|
|
consolidateMinOverlap = 0.55
|
|||
|
|
// forcedMinOverlap is the same bar for a review someone asked for.
|
|||
|
|
//
|
|||
|
|
// Low on purpose. Candidate selection is recall, not judgement — every
|
|||
|
|
// group goes to the model, which answers with an empty statement when the
|
|||
|
|
// records turn out to be different things. A strict bar only hides pairs
|
|||
|
|
// from the one component able to tell them apart: "我叫wizard,我是一个画家"
|
|||
|
|
// against "职业:我叫wizardchen,我是一个作家" shares 0.50 of its tokens and
|
|||
|
|
// so never reached the model, yet resolving exactly that contradiction is
|
|||
|
|
// why someone presses the button.
|
|||
|
|
forcedMinOverlap = 0.3
|
|||
|
|
// consolidateMinCosine and forcedMinCosine are the same two bars for
|
|||
|
|
// memories that were embedded. Wording overlap cannot see that "喜欢用 Go"
|
|||
|
|
// and "偏好 Golang 开发" are one preference; the vectors already stored for
|
|||
|
|
// recall can, at no extra model call.
|
|||
|
|
consolidateMinCosine = 0.86
|
|||
|
|
forcedMinCosine = 0.75
|
|||
|
|
// staleTaskAge is how long a task can go unmentioned before it stops
|
|||
|
|
// competing for space. "I'm refactoring payments this week" is worth
|
|||
|
|
// recalling this week and misleading three months later.
|
|||
|
|
staleTaskAge = 45 * 24 * time.Hour
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// consolidateIfDue reviews the whole store for one subject, at most once a day.
|
|||
|
|
//
|
|||
|
|
// Distillation only ever looks at the newest conversation, which is the right
|
|||
|
|
// scope for a single turn and the wrong scope for noticing that five turns
|
|||
|
|
// across three weeks have recorded the same preference five slightly different
|
|||
|
|
// ways, or that a task from last quarter is now just noise. Both Generative
|
|||
|
|
// Agents' reflection step and MemoryOS's segmented store treat this offline
|
|||
|
|
// pass as a separate stage for the same reason: no per-turn call can see it.
|
|||
|
|
//
|
|||
|
|
// It never runs on the request path.
|
|||
|
|
func (s *Service) consolidateIfDue(
|
|||
|
|
ctx context.Context, scope interfaces.MemoryScope, cfg *types.MemoryConfig, modelID string,
|
|||
|
|
) {
|
|||
|
|
s.reviewStore(ctx, scope, cfg, modelID, false)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ConsolidateNow reviews the caller's store immediately, without waiting for
|
|||
|
|
// the daily maintenance pass that rides along on distillation.
|
|||
|
|
func (s *Service) ConsolidateNow(ctx context.Context) (*types.MemoryConsolidationResult, error) {
|
|||
|
|
scope, cfg, ok := s.enabledScope(ctx)
|
|||
|
|
if !ok {
|
|||
|
|
return nil, ErrMemoryDisabled
|
|||
|
|
}
|
|||
|
|
if _, err := s.repo.EnsureSubject(ctx, scope); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
modelID := s.extractionModelID(ctx, cfg, types.MemoryExtractPayload{})
|
|||
|
|
return s.reviewStore(ctx, scope, cfg, modelID, true), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Service) reviewStore(
|
|||
|
|
ctx context.Context,
|
|||
|
|
scope interfaces.MemoryScope,
|
|||
|
|
cfg *types.MemoryConfig,
|
|||
|
|
modelID string,
|
|||
|
|
force bool,
|
|||
|
|
) *types.MemoryConsolidationResult {
|
|||
|
|
result := &types.MemoryConsolidationResult{}
|
|||
|
|
if err := checkBackgroundExtraction(ctx); err != nil {
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
subject, err := s.repo.GetSubject(ctx, scope)
|
|||
|
|
if err != nil || subject == nil {
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Warnf(ctx, "memory: load subject for consolidation failed: %v", err)
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
// Two clocks, because the two callers are rate limited for different
|
|||
|
|
// reasons and must not silence each other. The daily pass is maintenance
|
|||
|
|
// nobody asked for, so it waits a day. A review someone pressed a button
|
|||
|
|
// for waits only long enough that the button cannot be held down: the
|
|||
|
|
// endpoint is Viewer-level and each press is worth up to forcedMaxClusters
|
|||
|
|
// model calls, which a store the model keeps declining would repeat
|
|||
|
|
// indefinitely.
|
|||
|
|
last, interval := subject.ConsolidatedAt, consolidateInterval
|
|||
|
|
if force {
|
|||
|
|
last, interval = subject.ForcedConsolidatedAt, forcedConsolidateInterval
|
|||
|
|
}
|
|||
|
|
if last != nil && time.Since(*last) < interval {
|
|||
|
|
if force {
|
|||
|
|
result.Skipped = types.MemoryConsolidationSkipTooSoon
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
if force {
|
|||
|
|
if err := s.repo.MarkForcedConsolidated(ctx, scope); err != nil {
|
|||
|
|
logger.Warnf(ctx, "memory: mark forced consolidation failed: %v", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Expiry first: an expired task should not be a merge candidate.
|
|||
|
|
if archived, err := s.repo.ExpireOverdue(ctx, scope); err != nil {
|
|||
|
|
logger.Warnf(ctx, "memory: expire overdue failed: %v", err)
|
|||
|
|
} else {
|
|||
|
|
result.Expired = int(archived)
|
|||
|
|
if archived > 0 {
|
|||
|
|
logger.Infof(ctx, "memory: archived %d expired memories for %s", archived, scope.SubjectID)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// The whole store, not a page of it. A fixed 200 used to bound this, which
|
|||
|
|
// meant two duplicates that were both old could never meet: the pass only
|
|||
|
|
// ever saw the newest rows, and consolidation is the one stage whose job is
|
|||
|
|
// to notice things that drifted apart over weeks.
|
|||
|
|
//
|
|||
|
|
// Reading more costs no model calls. Candidate selection is local — token
|
|||
|
|
// overlap plus the vectors recall already stored — and the number of groups
|
|||
|
|
// actually put to a model is capped separately by maxClusters.
|
|||
|
|
items, _, err := s.repo.ListItems(ctx, scope, types.MemoryStatusActive, cfg.EffectiveMaxItems(), 0)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Warnf(ctx, "memory: consolidation list failed: %v", err)
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
result.Reviewed = len(items)
|
|||
|
|
result.Demoted = s.demoteStaleTasks(ctx, scope, items)
|
|||
|
|
if force || len(items) >= consolidateMinItems {
|
|||
|
|
result.Merged, result.Candidates, result.Skipped =
|
|||
|
|
s.mergeRedundant(ctx, scope, cfg, modelID, items, force)
|
|||
|
|
} else {
|
|||
|
|
result.Skipped = types.MemoryConsolidationSkipTooFewItems
|
|||
|
|
}
|
|||
|
|
if err := checkBackgroundExtraction(ctx); err != nil {
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Vectors for anything written before an embedding model existed, or while
|
|||
|
|
// it was unreachable. Bounded per run, so a large backlog drains over days
|
|||
|
|
// instead of stalling one maintenance pass.
|
|||
|
|
s.backfillEmbeddings(ctx, scope, cfg)
|
|||
|
|
if err := checkBackgroundExtraction(ctx); err != nil {
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := s.repo.MarkConsolidated(ctx, scope); err != nil {
|
|||
|
|
logger.Warnf(ctx, "memory: mark consolidated failed: %v", err)
|
|||
|
|
}
|
|||
|
|
if result.Merged > 0 || result.Demoted > 0 || result.Expired > 0 {
|
|||
|
|
s.rebuildBlock(ctx, scope)
|
|||
|
|
}
|
|||
|
|
// A review that does nothing is the common case, and until it says why it
|
|||
|
|
// is indistinguishable from one that is broken.
|
|||
|
|
if force || result.Merged > 0 || result.Demoted > 0 || result.Expired > 0 {
|
|||
|
|
logger.Infof(ctx,
|
|||
|
|
"memory: consolidation reviewed %d, candidates %d, merged %d, demoted %d, expired %d, skipped=%q for %s",
|
|||
|
|
result.Reviewed, result.Candidates, result.Merged, result.Demoted, result.Expired,
|
|||
|
|
result.Skipped, scope.SubjectID)
|
|||
|
|
}
|
|||
|
|
return result
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// demoteStaleTasks lowers the importance of tasks nobody has mentioned in
|
|||
|
|
// months.
|
|||
|
|
//
|
|||
|
|
// Deleting them would be wrong — the user never said they finished, and we do
|
|||
|
|
// not delete what we were told. Lowering importance is enough: it drops them
|
|||
|
|
// out of the resident block and makes them the first to go when the store hits
|
|||
|
|
// its cap, while leaving them visible and explainable in the memory manager.
|
|||
|
|
func (s *Service) demoteStaleTasks(
|
|||
|
|
ctx context.Context, scope interfaces.MemoryScope, items []*types.MemoryItem,
|
|||
|
|
) int {
|
|||
|
|
cutoff := time.Now().Add(-staleTaskAge)
|
|||
|
|
demoted := 0
|
|||
|
|
for _, item := range items {
|
|||
|
|
if err := checkBackgroundExtraction(ctx); err != nil {
|
|||
|
|
return demoted
|
|||
|
|
}
|
|||
|
|
if item == nil || item.Kind != types.MemoryKindTask || item.Importance <= 1 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
last := item.ValidFrom
|
|||
|
|
if item.LastUsedAt != nil && item.LastUsedAt.After(last) {
|
|||
|
|
last = *item.LastUsedAt
|
|||
|
|
}
|
|||
|
|
if last.After(cutoff) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
err := s.repo.UpdateItemContent(ctx, scope, item.ID, item.Content, item.NormalizedKey, 1)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Warnf(ctx, "memory: demote stale task failed: %v", err)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
demoted++
|
|||
|
|
}
|
|||
|
|
return demoted
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// mergeRedundant folds groups of near-duplicate memories into one statement.
|
|||
|
|
//
|
|||
|
|
// candidates is how many groups were found, which is not len(clusters) after
|
|||
|
|
// capping — it is what tells a caller whether an empty result means "nothing
|
|||
|
|
// looked alike" or "the model said no".
|
|||
|
|
func (s *Service) mergeRedundant(
|
|||
|
|
ctx context.Context,
|
|||
|
|
scope interfaces.MemoryScope,
|
|||
|
|
cfg *types.MemoryConfig,
|
|||
|
|
modelID string,
|
|||
|
|
items []*types.MemoryItem,
|
|||
|
|
force bool,
|
|||
|
|
) (merged int, candidates int, skipped string) {
|
|||
|
|
minOverlap, minCosine := consolidateMinOverlap, consolidateMinCosine
|
|||
|
|
maxClusters := consolidateMaxClusters
|
|||
|
|
if force {
|
|||
|
|
minOverlap, minCosine = forcedMinOverlap, forcedMinCosine
|
|||
|
|
maxClusters = forcedMaxClusters
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
clusters := s.mergeCandidates(ctx, scope, cfg, items, minOverlap, minCosine)
|
|||
|
|
candidates = len(clusters)
|
|||
|
|
if candidates == 0 {
|
|||
|
|
return 0, 0, types.MemoryConsolidationSkipNoCandidates
|
|||
|
|
}
|
|||
|
|
if len(clusters) > maxClusters {
|
|||
|
|
clusters = clusters[:maxClusters]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
declined := 0
|
|||
|
|
for _, cluster := range clusters {
|
|||
|
|
statement, unavailable := s.callConsolidationModel(ctx, modelID, cluster)
|
|||
|
|
if unavailable {
|
|||
|
|
// Only the model may decide that two memories say the same thing.
|
|||
|
|
// Merging on token overlap alone would supersede wordings the user
|
|||
|
|
// gave us on the strength of a heuristic that was never meant to
|
|||
|
|
// judge, so the review stops here and reports why.
|
|||
|
|
return merged, candidates, types.MemoryConsolidationSkipModelUnavailable
|
|||
|
|
}
|
|||
|
|
statement = types.SanitizeMemoryContent(statement)
|
|||
|
|
if statement == "" {
|
|||
|
|
declined++
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
primary := cluster[0]
|
|||
|
|
replacement, err := s.write(ctx, scope, cfg, types.MemoryItem{
|
|||
|
|
Kind: primary.Kind,
|
|||
|
|
Topic: primary.Topic,
|
|||
|
|
Content: statement,
|
|||
|
|
Importance: primary.Importance,
|
|||
|
|
Origin: primary.Origin,
|
|||
|
|
SourceSessionID: primary.SourceSessionID,
|
|||
|
|
SourceMessageID: primary.SourceMessageID,
|
|||
|
|
})
|
|||
|
|
if err != nil || replacement == nil {
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Warnf(ctx, "memory: consolidation write failed: %v", err)
|
|||
|
|
}
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
// Supersede rather than delete: the old wording keeps its dates, so the
|
|||
|
|
// memory manager can still explain what this statement used to be and
|
|||
|
|
// when it changed.
|
|||
|
|
for _, item := range cluster {
|
|||
|
|
if item.ID == replacement.ID {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if err := s.repo.SupersedeItem(ctx, scope, item.ID, replacement.ID); err != nil {
|
|||
|
|
logger.Warnf(ctx, "memory: supersede during consolidation failed: %v", err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
merged++
|
|||
|
|
}
|
|||
|
|
if merged == 0 && declined > 0 {
|
|||
|
|
return 0, candidates, types.MemoryConsolidationSkipModelDeclined
|
|||
|
|
}
|
|||
|
|
return merged, candidates, ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// mergeCandidates groups memories that might be saying the same thing.
|
|||
|
|
//
|
|||
|
|
// Two signals, either of which is enough. Wording overlap catches restatements
|
|||
|
|
// of the same sentence; cosine over the vectors already stored for recall
|
|||
|
|
// catches the same claim said differently, which no amount of token counting
|
|||
|
|
// can. Neither decides anything — every group is put to the model.
|
|||
|
|
func (s *Service) mergeCandidates(
|
|||
|
|
ctx context.Context,
|
|||
|
|
scope interfaces.MemoryScope,
|
|||
|
|
cfg *types.MemoryConfig,
|
|||
|
|
items []*types.MemoryItem,
|
|||
|
|
minOverlap, minCosine float64,
|
|||
|
|
) [][]*types.MemoryItem {
|
|||
|
|
// Sets rather than slices: clustering compares every surviving pair, so a
|
|||
|
|
// store at the 2000-item cap runs this predicate a couple of million times
|
|||
|
|
// and rebuilding both token sets inside each call is what would make
|
|||
|
|
// reviewing the whole store too slow to do on a request.
|
|||
|
|
tokens := make(map[string]map[string]struct{}, len(items))
|
|||
|
|
for _, item := range items {
|
|||
|
|
if item == nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
tokens[item.ID] = tokenSet(tokenize(item.Topic + " " + item.Content))
|
|||
|
|
}
|
|||
|
|
vectors := s.storedVectors(ctx, scope, cfg, items)
|
|||
|
|
|
|||
|
|
return clusterBy(items, func(a, b *types.MemoryItem) bool {
|
|||
|
|
if jaccardSets(tokens[a.ID], tokens[b.ID]) >= minOverlap {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
va, vb := vectors[a.ID], vectors[b.ID]
|
|||
|
|
if len(va) == 0 || len(vb) == 0 {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
return types.CosineSimilarity(va, vb) >= minCosine
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// storedVectors loads the vectors these memories already have.
|
|||
|
|
//
|
|||
|
|
// It never embeds anything: a review walks the whole store, and embedding it
|
|||
|
|
// on every pass would cost far more than the merges are worth. A memory
|
|||
|
|
// without a vector is simply matched on wording alone until the backfill at
|
|||
|
|
// the end of the review catches it.
|
|||
|
|
func (s *Service) storedVectors(
|
|||
|
|
ctx context.Context,
|
|||
|
|
scope interfaces.MemoryScope,
|
|||
|
|
cfg *types.MemoryConfig,
|
|||
|
|
items []*types.MemoryItem,
|
|||
|
|
) map[string][]float32 {
|
|||
|
|
modelID, ok := s.embedder(ctx, cfg)
|
|||
|
|
if !ok {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
ids := make([]string, 0, len(items))
|
|||
|
|
for _, item := range items {
|
|||
|
|
if item != nil && item.ID != "" {
|
|||
|
|
ids = append(ids, item.ID)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
vectors, err := s.repo.ItemEmbeddings(ctx, scope, ids, modelID)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Warnf(ctx, "memory: load embeddings for consolidation failed: %v", err)
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
return vectors
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// clusterSimilar groups memories of the same kind whose wording says nearly the
|
|||
|
|
// same thing, at the bar an unattended pass uses.
|
|||
|
|
func clusterSimilar(items []*types.MemoryItem) [][]*types.MemoryItem {
|
|||
|
|
return clusterBy(items, func(a, b *types.MemoryItem) bool {
|
|||
|
|
return jaccard(
|
|||
|
|
tokenize(a.Topic+" "+a.Content),
|
|||
|
|
tokenize(b.Topic+" "+b.Content),
|
|||
|
|
) >= consolidateMinOverlap
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// clusterBy groups memories of the same kind that same reports as one thing.
|
|||
|
|
// Groups of one are not returned.
|
|||
|
|
func clusterBy(
|
|||
|
|
items []*types.MemoryItem, same func(a, b *types.MemoryItem) bool,
|
|||
|
|
) [][]*types.MemoryItem {
|
|||
|
|
var clusters [][]*types.MemoryItem
|
|||
|
|
taken := make(map[string]bool, len(items))
|
|||
|
|
|
|||
|
|
for i, item := range items {
|
|||
|
|
if item == nil || taken[item.ID] {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
group := []*types.MemoryItem{item}
|
|||
|
|
for _, other := range items[i+1:] {
|
|||
|
|
if other == nil || taken[other.ID] || other.Kind != item.Kind {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if !same(item, other) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
group = append(group, other)
|
|||
|
|
taken[other.ID] = true
|
|||
|
|
}
|
|||
|
|
if len(group) < 2 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
taken[item.ID] = true
|
|||
|
|
clusters = append(clusters, group)
|
|||
|
|
}
|
|||
|
|
return clusters
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// jaccard is the overlap between two token lists.
|
|||
|
|
func jaccard(a, b []string) float64 {
|
|||
|
|
return jaccardSets(tokenSet(a), tokenSet(b))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// tokenSet deduplicates tokens so repeated comparisons of the same memory do
|
|||
|
|
// not rebuild its side of the overlap each time.
|
|||
|
|
func tokenSet(tokens []string) map[string]struct{} {
|
|||
|
|
set := make(map[string]struct{}, len(tokens))
|
|||
|
|
for _, token := range tokens {
|
|||
|
|
set[token] = struct{}{}
|
|||
|
|
}
|
|||
|
|
return set
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// jaccardSets is the overlap between two prepared token sets.
|
|||
|
|
func jaccardSets(a, b map[string]struct{}) float64 {
|
|||
|
|
if len(a) == 0 || len(b) == 0 {
|
|||
|
|
return 0
|
|||
|
|
}
|
|||
|
|
// Walk the smaller side; the result is symmetric either way.
|
|||
|
|
if len(b) < len(a) {
|
|||
|
|
a, b = b, a
|
|||
|
|
}
|
|||
|
|
shared := 0
|
|||
|
|
for token := range a {
|
|||
|
|
if _, ok := b[token]; ok {
|
|||
|
|
shared++
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
union := len(a) + len(b) - shared
|
|||
|
|
if union == 0 {
|
|||
|
|
return 0
|
|||
|
|
}
|
|||
|
|
return float64(shared) / float64(union)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const consolidationSystemPrompt = `你在整理一个人的长期记忆。下面几条记录说的是同一件事,请合并成一条。
|
|||
|
|
|
|||
|
|
规则:
|
|||
|
|
- 只用这些记录里已有的信息,不要补充、不要推测。
|
|||
|
|
- 如果它们互相矛盾,以日期最新的一条为准。
|
|||
|
|
- 保留最具体的细节(具体的名称、数字、版本),丢掉重复的说法。
|
|||
|
|
- 用记录本身的语言,一句话,不超过 60 字。
|
|||
|
|
- 只输出 JSON:{"statement":"合并后的一句话"}
|
|||
|
|
- 如果这些记录其实不是同一件事,输出 {"statement":""}。`
|
|||
|
|
|
|||
|
|
var consolidationSchema = json.RawMessage(`{
|
|||
|
|
"type": "object",
|
|||
|
|
"properties": {"statement": {"type": "string"}},
|
|||
|
|
"required": ["statement"]
|
|||
|
|
}`)
|
|||
|
|
|
|||
|
|
// callConsolidationModel asks the model to merge one cluster.
|
|||
|
|
func (s *Service) callConsolidationModel(
|
|||
|
|
ctx context.Context, modelID string, cluster []*types.MemoryItem,
|
|||
|
|
) (statement string, unavailable bool) {
|
|||
|
|
if modelID == "" || s.modelService == nil {
|
|||
|
|
return "", true
|
|||
|
|
}
|
|||
|
|
chatModel, err := s.modelService.GetChatModel(ctx, modelID)
|
|||
|
|
if err != nil || chatModel == nil {
|
|||
|
|
logger.Warnf(ctx, "memory: consolidation model unavailable: %v", err)
|
|||
|
|
return "", true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var b strings.Builder
|
|||
|
|
for _, item := range cluster {
|
|||
|
|
b.WriteString(fmt.Sprintf("- (%s) %s\n",
|
|||
|
|
item.ValidFrom.Format("2006-01-02"), types.SanitizeMemoryContent(item.Content)))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Thinking off, for the reason given on completeExtraction: a reasoning
|
|||
|
|
// model spends this whole budget on its own deliberation and returns
|
|||
|
|
// nothing, which here would silently skip every merge.
|
|||
|
|
thinking := false
|
|||
|
|
if err := checkBackgroundExtraction(ctx); err != nil {
|
|||
|
|
return "", true
|
|||
|
|
}
|
|||
|
|
response, err := chatModel.Chat(ctx, []chat.Message{
|
|||
|
|
{Role: "system", Content: consolidationSystemPrompt},
|
|||
|
|
{Role: "user", Content: b.String()},
|
|||
|
|
}, &chat.ChatOptions{
|
|||
|
|
Temperature: 0,
|
|||
|
|
MaxCompletionTokens: 600,
|
|||
|
|
Thinking: &thinking,
|
|||
|
|
Format: consolidationSchema,
|
|||
|
|
})
|
|||
|
|
if err := checkBackgroundExtraction(ctx); err != nil {
|
|||
|
|
return "", true
|
|||
|
|
}
|
|||
|
|
if err != nil || response == nil {
|
|||
|
|
logger.Warnf(ctx, "memory: consolidation call failed: %v", err)
|
|||
|
|
return "", true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
content := strings.TrimSpace(response.Content)
|
|||
|
|
start := strings.Index(content, "{")
|
|||
|
|
end := strings.LastIndex(content, "}")
|
|||
|
|
if start < 0 || end <= start {
|
|||
|
|
return "", false
|
|||
|
|
}
|
|||
|
|
var parsed struct {
|
|||
|
|
Statement string `json:"statement"`
|
|||
|
|
}
|
|||
|
|
if err := json.Unmarshal([]byte(content[start:end+1]), &parsed); err != nil {
|
|||
|
|
return "", false
|
|||
|
|
}
|
|||
|
|
return strings.TrimSpace(parsed.Statement), false
|
|||
|
|
}
|