mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #12104 from chuiwenwei/feat/memory-omitted-matches
feat(memory): report omitted matches on the memory tool paths
This commit is contained in:
4 files changed
+184
-11
No files matched your search
@@ -437,7 +437,7 @@ func buildRecallBlock(hits []RecallHit, budget, omittedByLimit int) ([]RecallHit
|
||||
}
|
||||
block := prefix + strings.Join(entries, "")
|
||||
if total := omittedByLimit + omittedByBudget; total > 0 {
|
||||
note := fmt.Sprintf("- omitted=%d additional relevant fact(s) because of the recall limit or character budget\n", total)
|
||||
note := omittedNote(total, "relevant fact(s)", "the recall limit or character budget") + "\n"
|
||||
if utf8.RuneCountInString(block+note+close) <= budget {
|
||||
block += note
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestExpiryIsAHardBoundaryExcludedFromRecall(t *testing.T) {
|
||||
t.Fatalf("expired facts must never be auto-recalled: %+v", result.Hits)
|
||||
}
|
||||
// Explicit search still finds it — expiry hides nothing from management.
|
||||
hits, err := searchMemories(t.Context(), store, "release branch", "", "", 8)
|
||||
hits, _, err := searchMemories(t.Context(), store, "release branch", "", "", 8)
|
||||
if err != nil || len(hits) != 1 {
|
||||
t.Fatalf("explicit search should still see expired facts, got %v %v", hits, err)
|
||||
}
|
||||
|
||||
@@ -72,11 +72,11 @@ func (t recallTool) Execute(ctx context.Context, args json.RawMessage) (string,
|
||||
limit := clampRecallLimit(in.Limit)
|
||||
switch strings.TrimSpace(in.Operation) {
|
||||
case "search":
|
||||
hits, err := searchMemories(ctx, t.store, in.Query, memType, memScope, limit)
|
||||
hits, omitted, err := searchMemories(ctx, t.store, in.Query, memType, memScope, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return formatMemoryHits(in.Query, hits), nil
|
||||
return formatMemoryHits(in.Query, hits, omitted), nil
|
||||
case "read":
|
||||
m, ok := readMemoryByName(t.store, in.Name)
|
||||
if !ok {
|
||||
@@ -107,20 +107,20 @@ type memoryDoc struct {
|
||||
length int
|
||||
}
|
||||
|
||||
func searchMemories(ctx context.Context, store Store, query string, typ Type, scope FactScope, limit int) ([]memoryHit, error) {
|
||||
func searchMemories(ctx context.Context, store Store, query string, typ Type, scope FactScope, limit int) ([]memoryHit, int, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("query is required")
|
||||
return nil, 0, fmt.Errorf("query is required")
|
||||
}
|
||||
queryTerms, err := retrieval.QueryTerms(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
memories := filterMemories(store.ListAll(), typ, scope)
|
||||
docs := make([]memoryDoc, 0, len(memories))
|
||||
for _, m := range memories {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
text := memorySearchText(m)
|
||||
terms := retrieval.Tokens(text)
|
||||
@@ -135,7 +135,7 @@ func searchMemories(ctx context.Context, store Store, query string, typ Type, sc
|
||||
})
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return nil, nil
|
||||
return nil, 0, nil
|
||||
}
|
||||
counts := make([]map[string]int, 0, len(docs))
|
||||
totalLen := 0
|
||||
@@ -164,13 +164,15 @@ func searchMemories(ctx context.Context, store Store, query string, typ Type, sc
|
||||
}
|
||||
return hits[i].Score > hits[j].Score
|
||||
})
|
||||
ranked := len(hits)
|
||||
hits = retrieval.KeepTopRelativeScore(hits, recallScoreFloor, func(hit memoryHit) float64 {
|
||||
return hit.Score
|
||||
})
|
||||
omitted := countOmitted(ranked, len(hits), limit)
|
||||
if len(hits) > limit {
|
||||
hits = hits[:limit]
|
||||
}
|
||||
return hits, nil
|
||||
return hits, omitted, nil
|
||||
}
|
||||
|
||||
func recallTypeFilter(s string) (Type, error) {
|
||||
@@ -225,8 +227,10 @@ func memorySearchText(m Memory) string {
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func formatMemoryHits(query string, hits []memoryHit) string {
|
||||
func formatMemoryHits(query string, hits []memoryHit, omitted int) string {
|
||||
if len(hits) == 0 {
|
||||
// KeepTopRelativeScore always keeps the top hit, so an empty result means
|
||||
// nothing matched; the omission note below only belongs to a real result.
|
||||
return strings.Join([]string{
|
||||
"No saved memories matched " + strconvQuote(query) + ".",
|
||||
"",
|
||||
@@ -243,6 +247,9 @@ func formatMemoryHits(query string, hits []memoryHit) string {
|
||||
fmt.Fprintf(&b, "\n%d. score=%.3f id=%s revision=%d name=%s scope=%s type=%s title=%s\n reference: %s\n description: %s\n snippet: %s\n",
|
||||
i+1, hit.Score, m.ID, m.Revision, m.Name, NormalizeFactScope(string(m.Scope)), NormalizeType(string(m.Type)), displayTitle(m.Title, m.Name), providerMemoryReference(m), oneLine(m.Description), hit.Snippet)
|
||||
}
|
||||
if omitted > 0 {
|
||||
fmt.Fprintf(&b, "\n%s\n", omittedNote(omitted, "fact(s)", "the result limit or the relevance floor"))
|
||||
}
|
||||
b.WriteString("\nUse operation=\"read\" with a stable memory id to inspect the full saved fact.")
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
@@ -266,7 +273,9 @@ func formatMemoryList(_ Store, memories []Memory, limit int) string {
|
||||
if len(memories) == 0 {
|
||||
return "No saved memories found."
|
||||
}
|
||||
omitted := 0
|
||||
if len(memories) > limit {
|
||||
omitted = len(memories) - limit
|
||||
memories = memories[:limit]
|
||||
}
|
||||
var b strings.Builder
|
||||
@@ -275,9 +284,28 @@ func formatMemoryList(_ Store, memories []Memory, limit int) string {
|
||||
fmt.Fprintf(&b, "- [%s](%s.md) reference=%s id=%s revision=%d scope=%s type=%s - %s\n",
|
||||
displayTitle(m.Title, m.Name), m.Name, providerMemoryReference(m), m.ID, m.Revision, NormalizeFactScope(string(m.Scope)), NormalizeType(string(m.Type)), oneLine(m.Description))
|
||||
}
|
||||
if omitted > 0 {
|
||||
fmt.Fprintf(&b, "%s\n", omittedNote(omitted, "fact(s)", "the list limit"))
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
// countOmitted reports what a ranked result dropped: the matches the relative
|
||||
// score floor compacted away, plus the tail the caller's limit cut off.
|
||||
func countOmitted(ranked, kept, limit int) int {
|
||||
omitted := ranked - kept
|
||||
if kept > limit {
|
||||
omitted += kept - limit
|
||||
}
|
||||
return omitted
|
||||
}
|
||||
|
||||
// omittedNote is the one place the omission line is spelled, so a tool result and
|
||||
// the automatic recall block cannot drift apart (#11853).
|
||||
func omittedNote(omitted int, noun, because string) string {
|
||||
return fmt.Sprintf("- omitted=%d additional %s because of %s", omitted, noun, because)
|
||||
}
|
||||
|
||||
func providerMemoryReference(m Memory) string {
|
||||
return string(NormalizeFactScope(string(m.Scope))) + "/" + slug(m.Name) + ".md"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package memory
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -324,3 +326,146 @@ func saveMemory(t *testing.T, store Store, m Memory) {
|
||||
t.Fatalf("Save(%s): %v", m.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallToolReportsOmittedMatches(t *testing.T) {
|
||||
store := Store{Dir: testenv.TempDir(t)}
|
||||
const facts = 12
|
||||
for i := range facts {
|
||||
saveMemory(t, store, Memory{
|
||||
Name: fmt.Sprintf("omitted-search-%02d", i),
|
||||
Description: "omitted search fixture",
|
||||
Type: TypeProject,
|
||||
Body: "omittedsearchterm",
|
||||
})
|
||||
}
|
||||
tl := NewRecallTool(store)
|
||||
|
||||
// More matches than the caller asked for: the model must be able to tell a
|
||||
// truncated result from a complete one, the way the automatic recall block
|
||||
// already reports what it dropped.
|
||||
out, err := tl.Execute(context.Background(), []byte(`{"operation":"search","query":"omittedsearchterm","limit":5}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute search: %v", err)
|
||||
}
|
||||
omitted, ok := omittedFromOutput(out)
|
||||
if !ok {
|
||||
t.Fatalf("search did not report what it dropped:\n%s", out)
|
||||
}
|
||||
if omitted != facts-5 {
|
||||
t.Fatalf("omitted=%d, want exactly %d (the floor kept all twelve, the limit cut five):\n%s", omitted, facts-5, out)
|
||||
}
|
||||
|
||||
// The negative control: a result that lost nothing must not claim otherwise.
|
||||
full, err := tl.Execute(context.Background(), []byte(`{"operation":"search","query":"omittedsearchterm","limit":20}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute search: %v", err)
|
||||
}
|
||||
if strings.Contains(full, "omitted=") {
|
||||
t.Fatalf("a complete search reported an omission:\n%s", full)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallToolReportsFloorDrops(t *testing.T) {
|
||||
store := Store{Dir: testenv.TempDir(t)}
|
||||
saveMemory(t, store, Memory{
|
||||
Name: "rare-floor-rule",
|
||||
Description: "Rare floor rule",
|
||||
Type: TypeProject,
|
||||
Body: "rarefloorterm common common common",
|
||||
})
|
||||
for i := range 12 {
|
||||
saveMemory(t, store, Memory{
|
||||
Name: fmt.Sprintf("floor-note-%02d", i),
|
||||
Description: "Common note",
|
||||
Type: TypeProject,
|
||||
Body: "common",
|
||||
})
|
||||
}
|
||||
|
||||
out, err := NewRecallTool(store).Execute(context.Background(), []byte(`{"operation":"search","query":"rarefloorterm common","limit":20}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute search: %v", err)
|
||||
}
|
||||
// Nothing was dropped by the limit, so this note can only come from the
|
||||
// relevance floor: the drop the tool used to make silently.
|
||||
if omitted, ok := omittedFromOutput(out); !ok || omitted != 12 {
|
||||
t.Fatalf("floor drops not reported (omitted=%d ok=%v):\n%s", omitted, ok, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecallToolReportsOmittedListEntries(t *testing.T) {
|
||||
store := Store{Dir: testenv.TempDir(t)}
|
||||
for i := range 12 {
|
||||
saveMemory(t, store, Memory{
|
||||
Name: fmt.Sprintf("omitted-list-%02d", i),
|
||||
Description: "omitted list fixture",
|
||||
Type: TypeProject,
|
||||
Body: "listed fact",
|
||||
})
|
||||
}
|
||||
tl := NewRecallTool(store)
|
||||
|
||||
out, err := tl.Execute(context.Background(), []byte(`{"operation":"list","limit":5}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute list: %v", err)
|
||||
}
|
||||
if omitted, ok := omittedFromOutput(out); !ok || omitted != 7 {
|
||||
t.Fatalf("list omitted=%d ok=%v, want 7:\n%s", omitted, ok, out)
|
||||
}
|
||||
|
||||
full, err := tl.Execute(context.Background(), []byte(`{"operation":"list","limit":20}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute list: %v", err)
|
||||
}
|
||||
if strings.Contains(full, "omitted=") {
|
||||
t.Fatalf("a complete list reported an omission:\n%s", full)
|
||||
}
|
||||
}
|
||||
|
||||
// omittedFromOutput reads the tool's omission note, in the same shape the
|
||||
// automatic recall block uses: "- omitted=N ...".
|
||||
func omittedFromOutput(out string) (int, bool) {
|
||||
_, rest, ok := strings.Cut(out, "- omitted=")
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
digits, _, _ := strings.Cut(rest, " ")
|
||||
n, err := strconv.Atoi(digits)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func TestCountOmittedCountsBothReasons(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ranked, kept, limit int
|
||||
want int
|
||||
}{
|
||||
{"nothing dropped", 3, 3, 8, 0},
|
||||
{"floor only", 13, 1, 8, 12},
|
||||
{"limit only", 12, 12, 5, 7},
|
||||
{"floor and limit", 20, 12, 5, 15},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := countOmitted(tc.ranked, tc.kept, tc.limit); got != tc.want {
|
||||
t.Fatalf("countOmitted(%d, %d, %d) = %d, want %d", tc.ranked, tc.kept, tc.limit, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOmittedNoteIsTheRecallBlockWording(t *testing.T) {
|
||||
// The recall block and the tool paths must spell the omission line the same
|
||||
// way; this pins the one spelling both now call.
|
||||
got := omittedNote(3, "relevant fact(s)", "the recall limit or character budget")
|
||||
want := "- omitted=3 additional relevant fact(s) because of the recall limit or character budget"
|
||||
if got != want {
|
||||
t.Fatalf("omittedNote = %q, want %q", got, want)
|
||||
}
|
||||
if !strings.Contains(got, "- omitted=3 ") {
|
||||
t.Fatalf("omittedNote lost the count-first shape: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user