mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(memory): CJK bigram retrieval and write-time recall keywords
Two ends of the same lexical gap in saved-fact recall. Tokenizer: CJK runs now index as overlapping bigrams (lone runes stay single terms) - the standard CJK FTS unit. Per-rune unigrams let a query match scattered common characters, which the auto-recall side patched with a 'three matched CJK runes' rule; that patch retires, because two matched bigrams already mean a shared word pair or three-character run. One tokenizer serves every FTS surface (memory recall, auto recall, history search, product docs), so all four inherit the fix at the source. The unigram length inflation that biased BM25 normalization against mixed-language documents goes away with it. Keywords: recall stays lexical on purpose, but the write side has a model in the loop. remember gains an optional space-separated keywords field - synonyms, translations of key terms, related commands - stored in frontmatter, searched by both recall paths, never rendered into the index or the recall block. An update omitting keywords preserves them (inheritOnUpdate, same contract as scope). A paraphrased or cross- language query now reaches facts whose own words it never uses. Frontmatter serialization moves to render.go; boot goldens regenerated for the schema's new optional property.
This commit is contained in:
1 parent
75ea3e678c
commit
8bca8fad2f
14 files changed
+243
-102
No files matched your search
@@ -80,6 +80,8 @@ Each fact is a Markdown file with:
|
||||
- `created_at` and `updated_at` timestamps;
|
||||
- a human-readable name, title, and description;
|
||||
- an independent `type` and `scope`;
|
||||
- optional search `keywords` — aliases and translations of key terms that let
|
||||
a paraphrased or cross-language query reach the fact;
|
||||
- the Markdown body.
|
||||
|
||||
`type` classifies the content:
|
||||
@@ -118,7 +120,9 @@ not mutate the system prompt or tool schema.
|
||||
Recall is conservative:
|
||||
|
||||
- generic turns such as "continue" do not trigger recall;
|
||||
- distinctive lexical matches are ranked with BM25;
|
||||
- distinctive lexical matches are ranked with BM25 (CJK text is matched by
|
||||
character bigrams, so a hit needs a real word overlap, not scattered common
|
||||
characters);
|
||||
- project facts receive a small relevance preference;
|
||||
- stale facts are down-ranked, not silently deleted;
|
||||
- equivalent project facts suppress global fallbacks for that recall;
|
||||
|
||||
@@ -70,6 +70,8 @@ Reasonix 识别 `REASONIX.md`、`AGENTS.md`、`CLAUDE.md`,以及对应的 `.lo
|
||||
- `created_at`、`updated_at` 时间;
|
||||
- 便于阅读的 name、title 和 description;
|
||||
- 相互独立的 `type` 与 `scope`;
|
||||
- 可选的检索 `keywords` —— 关键词的同义词与双语别名,让换一种说法或换一种
|
||||
语言的提问也能命中这条事实;
|
||||
- Markdown 正文。
|
||||
|
||||
`type` 表示内容类别:
|
||||
@@ -102,7 +104,8 @@ type 不推导 scope。项目反馈仍只属于项目,全局 reference 仍然
|
||||
召回策略刻意保守:
|
||||
|
||||
- “继续”这类泛化回合不触发召回;
|
||||
- 用 BM25 排序有区分度的词法命中;
|
||||
- 用 BM25 排序有区分度的词法命中(CJK 文本按双字 bigram 匹配,命中需要真实的
|
||||
词语重叠,零散的常用字不算);
|
||||
- 项目事实有轻微相关性加权;
|
||||
- 过期事实只降权,不静默删除;
|
||||
- 本轮存在等价项目事实时,不再注入对应的全局 fallback;
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"SystemHash": "c4a54b2f61c5e15f",
|
||||
"ToolsHash": "a2d2aa4bdc381608",
|
||||
"PrefixHash": "32f42f908c5b78e6",
|
||||
"ToolsHash": "b3ee688369bfbb1c",
|
||||
"PrefixHash": "840628b3d4e8b03f",
|
||||
"LogRewriteVersion": 0,
|
||||
"ToolSchemaTokens": 12784
|
||||
"ToolSchemaTokens": 12884
|
||||
}
|
||||
@@ -1308,6 +1308,10 @@
|
||||
"description": "Stable memory id for an update. Prefer this over name when supplied by memory search/read.",
|
||||
"type": "string"
|
||||
},
|
||||
"keywords": {
|
||||
"description": "Space-separated search aliases a future query might use where the body's own words would miss: synonyms, translations of key terms (recall matching is lexical, so give Chinese facts English aliases and vice versa), related command or tool names. Omit when the body already carries the likely query words. When updating, omit to preserve existing keywords.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Stable project/\u003cname\u003e.md or global/\u003cname\u003e.md reference returned by memory search/read/list, or a short kebab-case slug for a new fact. Reusing a reference updates that exact memory. Omit to derive a new slug from the description.",
|
||||
"type": "string"
|
||||
|
||||
@@ -1314,6 +1314,10 @@
|
||||
"description": "Stable memory id for an update. Prefer this over name when supplied by memory search/read.",
|
||||
"type": "string"
|
||||
},
|
||||
"keywords": {
|
||||
"description": "Space-separated search aliases a future query might use where the body's own words would miss: synonyms, translations of key terms (recall matching is lexical, so give Chinese facts English aliases and vice versa), related command or tool names. Omit when the body already carries the likely query words. When updating, omit to preserve existing keywords.",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Stable project/\u003cname\u003e.md or global/\u003cname\u003e.md reference returned by memory search/read/list, or a short kebab-case slug for a new fact. Reusing a reference updates that exact memory. Omit to derive a new slug from the description.",
|
||||
"type": "string"
|
||||
|
||||
@@ -267,8 +267,12 @@ func matchedRecallTerms(queryTerms []string, counts map[string]int) []string {
|
||||
return matched
|
||||
}
|
||||
|
||||
// strongRecallMatch keeps automatic recall out of one-common-word territory.
|
||||
// Two matched terms are enough on their own: CJK terms are bigrams, so two of
|
||||
// them mean a shared two-character word pair or a three-character run — the
|
||||
// selectivity the retired per-rune "three matched runes" patch approximated.
|
||||
func strongRecallMatch(query string, queryTerms, matched []string) bool {
|
||||
if len(matched) >= 2 && (!allCJKRecallTerms(matched) || len(matched) >= 3) {
|
||||
if len(matched) >= 2 {
|
||||
return true
|
||||
}
|
||||
if len(matched) != 1 {
|
||||
@@ -281,18 +285,8 @@ func strongRecallMatch(query string, queryTerms, matched []string) bool {
|
||||
return distinctiveQueryTerm(query, term)
|
||||
}
|
||||
|
||||
func allCJKRecallTerms(terms []string) bool {
|
||||
for _, term := range terms {
|
||||
runes := []rune(term)
|
||||
if len(runes) != 1 || !unicode.In(runes[0], unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(terms) > 0
|
||||
}
|
||||
|
||||
func autoRecallSearchText(memory Memory) string {
|
||||
return strings.Join([]string{memory.Name, memory.Title, memory.Description, memory.Body}, "\n")
|
||||
return strings.Join([]string{memory.Name, memory.Title, memory.Description, memory.Keywords, memory.Body}, "\n")
|
||||
}
|
||||
|
||||
func distinctiveQueryTerm(query, normalizedTerm string) bool {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CJK bigram selectivity: a query phrase must match a real two-character
|
||||
// subsequence, not scattered common characters (the retired per-rune scorer
|
||||
// matched the latter).
|
||||
func TestAutoRecallCJKBigramsRequireRealSubsequence(t *testing.T) {
|
||||
store := recallTestStore(t)
|
||||
recallTestWrite(t, store.Dir, Memory{
|
||||
ID: "mem-migration", Name: "db-migration-compat", Title: "数据库迁移兼容性",
|
||||
Description: "数据库迁移必须保持向后兼容", Type: TypeProject,
|
||||
Scope: FactScopeProject, Body: "所有 schema 变更先加列后删列。",
|
||||
})
|
||||
recallTestWrite(t, store.Dir, Memory{
|
||||
ID: "mem-scattered", Name: "warehouse-stats", Title: "仓库统计口径",
|
||||
Description: "数值统计以据点仓库的库存为准", Type: TypeProject,
|
||||
Scope: FactScopeProject, Body: "库存数字每周核对一次。",
|
||||
})
|
||||
|
||||
result := AutoRecall(store, "数据库迁移怎么保证兼容", RecallOptions{})
|
||||
if len(result.Hits) == 0 {
|
||||
t.Fatalf("AutoRecall missed the real 数据库迁移 fact: %+v", result)
|
||||
}
|
||||
for _, hit := range result.Hits {
|
||||
if hit.Memory.ID == "mem-scattered" {
|
||||
t.Fatalf("scattered common characters must not match: %+v", result.Hits)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keywords bridge the lexical gap AutoRecall cannot cross: the fact's own
|
||||
// words never appear in the query, its saved aliases do.
|
||||
func TestAutoRecallMatchesThroughSavedKeywords(t *testing.T) {
|
||||
store := recallTestStore(t)
|
||||
recallTestWrite(t, store.Dir, Memory{
|
||||
ID: "mem-pnpm", Name: "package-manager", Title: "Package manager is pnpm",
|
||||
Description: "The project migrated from npm to pnpm", Type: TypeProject,
|
||||
Scope: FactScopeProject, Keywords: "依赖 安装 包管理 install dependencies",
|
||||
Body: "Use pnpm for every workspace task; npm and yarn lockfiles are gone.",
|
||||
})
|
||||
|
||||
result := AutoRecall(store, "依赖应该用什么工具安装", RecallOptions{})
|
||||
if len(result.Hits) != 1 || result.Hits[0].Memory.ID != "mem-pnpm" {
|
||||
t.Fatalf("keywords should carry the paraphrased query to the fact, got %+v", result.Hits)
|
||||
}
|
||||
if !strings.Contains(result.Hits[0].Reason, "依赖") && !strings.Contains(result.Hits[0].Reason, "安装") {
|
||||
t.Fatalf("reason should surface the matched alias, got %q", result.Hits[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRoundTripsKeywordsAndPreservesThemOnUpdate(t *testing.T) {
|
||||
store := recallTestStore(t)
|
||||
saved, err := store.SaveWithOptions(Memory{
|
||||
Name: "package-manager", Description: "The project uses pnpm",
|
||||
Keywords: "依赖 包管理", Body: "pnpm everywhere.",
|
||||
}, SaveOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, ok := store.Read(saved.Memory.ID)
|
||||
if !ok || loaded.Keywords != "依赖 包管理" {
|
||||
t.Fatalf("keywords lost in round trip: ok=%v %+v", ok, loaded)
|
||||
}
|
||||
|
||||
if _, err := store.SaveWithOptions(Memory{
|
||||
ID: saved.Memory.ID, Description: "The project uses pnpm 9",
|
||||
Body: "pnpm 9 everywhere.",
|
||||
}, SaveOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, ok := store.Read(saved.Memory.ID)
|
||||
if !ok || updated.Keywords != "依赖 包管理" {
|
||||
t.Fatalf("an update omitting keywords must preserve them, got %+v", updated)
|
||||
}
|
||||
if updated.Revision != 2 || !strings.Contains(updated.Body, "pnpm 9") {
|
||||
t.Fatalf("update did not land: %+v", updated)
|
||||
}
|
||||
}
|
||||
@@ -220,6 +220,7 @@ func memorySearchText(m Memory) string {
|
||||
string(NormalizeType(string(m.Type))),
|
||||
string(NormalizeFactScope(string(m.Scope))),
|
||||
m.Description,
|
||||
m.Keywords,
|
||||
m.Body,
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type rememberRequest struct {
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Scope string `json:"scope"`
|
||||
Keywords string `json:"keywords"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
@@ -59,6 +60,7 @@ func (rememberTool) Schema() json.RawMessage {
|
||||
"description": {"type": "string", "description": "One-line hook shown in the index — the phrase a future session reads to decide whether to open this memory. Make it specific."},
|
||||
"type": {"type": "string", "enum": ["user", "feedback", "project", "reference"], "description": "Category of the fact."},
|
||||
"scope": {"type": "string", "enum": ["project", "global"], "description": "Where the fact applies. For a new fact, omit for the safe default, project. When updating an existing name, omit to preserve its current scope. Use global only when it should affect every workspace."},
|
||||
"keywords": {"type": "string", "description": "Space-separated search aliases a future query might use where the body's own words would miss: synonyms, translations of key terms (recall matching is lexical, so give Chinese facts English aliases and vice versa), related command or tool names. Omit when the body already carries the likely query words. When updating, omit to preserve existing keywords."},
|
||||
"body": {"type": "string", "description": "The fact itself (Markdown). For feedback/project, include a \"**Why:**\" line and a \"**How to apply:**\" line; link related memories with [[their-name]]."}
|
||||
},
|
||||
"required": ["description", "body"]
|
||||
@@ -87,6 +89,7 @@ func (t rememberTool) Execute(ctx context.Context, args json.RawMessage) (string
|
||||
Description: in.Description,
|
||||
Type: NormalizeType(in.Type),
|
||||
Scope: factScope,
|
||||
Keywords: in.Keywords,
|
||||
Body: in.Body,
|
||||
}, SaveOptions{
|
||||
ExpectedRevision: in.ExpectedRevision,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Frontmatter serialization for saved facts: the YAML shape render emits and
|
||||
// the legacy type-routing compatibility it preserves.
|
||||
package memory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// memoryFrontmatter is the YAML shape render emits, mirroring the auto-memory
|
||||
// shape (name / description / metadata.type) so the files are interchangeable
|
||||
// with that ecosystem and re-readable by loadMemory. Marshaled by yaml.v3 so a
|
||||
// value containing ": ", '#', or quotes is escaped instead of corrupting the
|
||||
// block; plain values render byte-identically to the previous hand-built form.
|
||||
type memoryFrontmatter struct {
|
||||
ID string `yaml:"id,omitempty"`
|
||||
Revision int `yaml:"revision,omitempty"`
|
||||
CreatedAt string `yaml:"created_at,omitempty"`
|
||||
UpdatedAt string `yaml:"updated_at,omitempty"`
|
||||
Name string `yaml:"name"`
|
||||
Title string `yaml:"title,omitempty"`
|
||||
Desc string `yaml:"description"`
|
||||
Keywords string `yaml:"keywords,omitempty"`
|
||||
Metadata struct {
|
||||
Type string `yaml:"type"`
|
||||
FactType string `yaml:"fact_type,omitempty"`
|
||||
Scope string `yaml:"scope"`
|
||||
} `yaml:"metadata"`
|
||||
}
|
||||
|
||||
// render serializes a memory to frontmatter + body.
|
||||
func render(m Memory, name string) string {
|
||||
fm := memoryFrontmatter{
|
||||
ID: m.ID, Revision: m.Revision, Name: name, Title: oneLine(m.Title), Desc: oneLine(m.Description),
|
||||
Keywords: oneLine(m.Keywords),
|
||||
}
|
||||
if !m.CreatedAt.IsZero() {
|
||||
fm.CreatedAt = m.CreatedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if !m.UpdatedAt.IsZero() {
|
||||
fm.UpdatedAt = m.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
actualType := NormalizeType(string(m.Type))
|
||||
scope := NormalizeFactScope(string(m.Scope))
|
||||
compatType := previousReleaseRoutingType(actualType, scope)
|
||||
fm.Metadata.Type = string(compatType)
|
||||
if compatType != actualType {
|
||||
fm.Metadata.FactType = string(actualType)
|
||||
}
|
||||
fm.Metadata.Scope = string(scope)
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n")
|
||||
enc := yaml.NewEncoder(&b)
|
||||
enc.SetIndent(2)
|
||||
// Encoding a flat struct of strings cannot fail.
|
||||
_ = enc.Encode(fm)
|
||||
_ = enc.Close()
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString(strings.TrimSpace(m.Body))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// previousReleaseRoutingType keeps scope safe when an older Reasonix binary
|
||||
// shares the same state directory. Previous releases routed user/feedback to
|
||||
// GlobalDir and project/reference to Dir, so metadata.type remains a compatible
|
||||
// routing hint while metadata.fact_type preserves the independent new category.
|
||||
func previousReleaseRoutingType(actual Type, scope FactScope) Type {
|
||||
if scope == FactScopeGlobal {
|
||||
if actual == TypeUser || actual == TypeFeedback {
|
||||
return actual
|
||||
}
|
||||
return TypeUser
|
||||
}
|
||||
if actual == TypeProject || actual == TypeReference {
|
||||
return actual
|
||||
}
|
||||
return TypeProject
|
||||
}
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"reasonix/internal/config"
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
"reasonix/internal/frontmatter"
|
||||
@@ -86,6 +84,7 @@ type Memory struct {
|
||||
Description string // one-line summary used for the index and recall
|
||||
Type Type
|
||||
Scope FactScope // project by default; global only when explicitly requested
|
||||
Keywords string // search aliases (bilingual synonyms, related commands); recall-only, never rendered into the index
|
||||
Body string // the fact itself (Markdown)
|
||||
}
|
||||
|
||||
@@ -380,78 +379,6 @@ func repairOwnerWrite(root *os.Root, path string, dir bool) {
|
||||
_ = root.Chmod(path, info.Mode().Perm()|need)
|
||||
}
|
||||
|
||||
// memoryFrontmatter is the YAML shape render emits, mirroring the auto-memory
|
||||
// shape (name / description / metadata.type) so the files are interchangeable
|
||||
// with that ecosystem and re-readable by loadMemory. Marshaled by yaml.v3 so a
|
||||
// title or description containing ": ", '#', or quotes is escaped instead of
|
||||
// corrupting the block — frontmatter.Split returns an EMPTY map for
|
||||
// unparseable YAML, which would silently drop the memory's name/title/type on
|
||||
// the next load. Plain values render byte-identically to the previous
|
||||
// hand-built format.
|
||||
type memoryFrontmatter struct {
|
||||
ID string `yaml:"id,omitempty"`
|
||||
Revision int `yaml:"revision,omitempty"`
|
||||
CreatedAt string `yaml:"created_at,omitempty"`
|
||||
UpdatedAt string `yaml:"updated_at,omitempty"`
|
||||
Name string `yaml:"name"`
|
||||
Title string `yaml:"title,omitempty"`
|
||||
Desc string `yaml:"description"`
|
||||
Metadata struct {
|
||||
Type string `yaml:"type"`
|
||||
FactType string `yaml:"fact_type,omitempty"`
|
||||
Scope string `yaml:"scope"`
|
||||
} `yaml:"metadata"`
|
||||
}
|
||||
|
||||
// render serializes a memory to frontmatter + body.
|
||||
func render(m Memory, name string) string {
|
||||
fm := memoryFrontmatter{
|
||||
ID: m.ID, Revision: m.Revision, Name: name, Title: oneLine(m.Title), Desc: oneLine(m.Description),
|
||||
}
|
||||
if !m.CreatedAt.IsZero() {
|
||||
fm.CreatedAt = m.CreatedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if !m.UpdatedAt.IsZero() {
|
||||
fm.UpdatedAt = m.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
actualType := NormalizeType(string(m.Type))
|
||||
scope := NormalizeFactScope(string(m.Scope))
|
||||
compatType := previousReleaseRoutingType(actualType, scope)
|
||||
fm.Metadata.Type = string(compatType)
|
||||
if compatType != actualType {
|
||||
fm.Metadata.FactType = string(actualType)
|
||||
}
|
||||
fm.Metadata.Scope = string(scope)
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n")
|
||||
enc := yaml.NewEncoder(&b)
|
||||
enc.SetIndent(2)
|
||||
// Encoding a flat struct of strings cannot fail.
|
||||
_ = enc.Encode(fm)
|
||||
_ = enc.Close()
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString(strings.TrimSpace(m.Body))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// previousReleaseRoutingType keeps scope safe when an older Reasonix binary
|
||||
// shares the same state directory. Previous releases routed user/feedback to
|
||||
// GlobalDir and project/reference to Dir, so metadata.type remains a compatible
|
||||
// routing hint while metadata.fact_type preserves the independent new category.
|
||||
func previousReleaseRoutingType(actual Type, scope FactScope) Type {
|
||||
if scope == FactScopeGlobal {
|
||||
if actual == TypeUser || actual == TypeFeedback {
|
||||
return actual
|
||||
}
|
||||
return TypeUser
|
||||
}
|
||||
if actual == TypeProject || actual == TypeReference {
|
||||
return actual
|
||||
}
|
||||
return TypeProject
|
||||
}
|
||||
|
||||
// indexLineRe matches a managed index line so reindex/Delete can target the line
|
||||
// for one memory by its filename without disturbing the rest of a hand-edited
|
||||
// MEMORY.md.
|
||||
@@ -783,6 +710,7 @@ func loadMemory(path string) (Memory, bool) {
|
||||
Name: fm["name"],
|
||||
Title: fm["title"],
|
||||
Description: fm["description"],
|
||||
Keywords: fm["keywords"],
|
||||
Type: persistedFactType(fm),
|
||||
Scope: factScopeFromFrontmatter(fm["scope"]),
|
||||
Body: strings.TrimSpace(body),
|
||||
|
||||
@@ -88,6 +88,19 @@ func (s Store) MigrateV2() (MigrationReport, error) {
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// inheritOnUpdate keeps the update-omittable fields of an existing revision:
|
||||
// an update that leaves scope or keywords empty preserves them, it does not
|
||||
// clear them.
|
||||
func inheritOnUpdate(m Memory, existing Memory) Memory {
|
||||
if strings.TrimSpace(string(m.Scope)) == "" {
|
||||
m.Scope = existing.Scope
|
||||
}
|
||||
if strings.TrimSpace(m.Keywords) == "" {
|
||||
m.Keywords = existing.Keywords
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s Store) SaveWithOptions(m Memory, opts SaveOptions) (SaveResult, error) {
|
||||
memoryStoreMutationMu.Lock()
|
||||
defer memoryStoreMutationMu.Unlock()
|
||||
@@ -144,9 +157,7 @@ func (s Store) SaveWithOptions(m Memory, opts SaveOptions) (SaveResult, error) {
|
||||
m.ID = existing.ID
|
||||
m.Revision = existing.Revision + 1
|
||||
m.CreatedAt = existing.CreatedAt
|
||||
if strings.TrimSpace(string(m.Scope)) == "" {
|
||||
m.Scope = existing.Scope
|
||||
}
|
||||
m = inheritOnUpdate(m, existing)
|
||||
} else {
|
||||
m.ID = newMemoryID(m.Name, now)
|
||||
m.Revision = 1
|
||||
|
||||
@@ -8,12 +8,16 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Tokens lowercases Latin words and splits CJK text into single-rune terms. It
|
||||
// is intentionally simple: a local, dependency-free approximation of FTS token
|
||||
// matching for saved agent history and memory.
|
||||
// Tokens lowercases Latin words and splits CJK runs into overlapping bigrams
|
||||
// (a lone CJK rune stays a single term) — the standard CJK indexing unit
|
||||
// (Lucene's CJKAnalyzer, SQLite FTS): a bigram only matches a real two-rune
|
||||
// subsequence where per-rune unigrams matched scattered common characters.
|
||||
// Intentionally a local, dependency-free approximation of FTS matching.
|
||||
func Tokens(s string) []string {
|
||||
var out []string
|
||||
var b strings.Builder
|
||||
var prev rune
|
||||
cjkRun := 0
|
||||
flush := func() {
|
||||
if b.Len() == 0 {
|
||||
return
|
||||
@@ -21,18 +25,31 @@ func Tokens(s string) []string {
|
||||
out = append(out, b.String())
|
||||
b.Reset()
|
||||
}
|
||||
endCJK := func() {
|
||||
if cjkRun == 1 {
|
||||
out = append(out, string(prev))
|
||||
}
|
||||
cjkRun = 0
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case isCJK(r):
|
||||
flush()
|
||||
out = append(out, string(r))
|
||||
if cjkRun > 0 {
|
||||
out = append(out, string([]rune{prev, r}))
|
||||
}
|
||||
prev = r
|
||||
cjkRun++
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_':
|
||||
endCJK()
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
default:
|
||||
flush()
|
||||
endCJK()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
endCJK()
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,19 @@ import (
|
||||
)
|
||||
|
||||
func TestTokensHandlesLatinAndCJK(t *testing.T) {
|
||||
got := Tokens("BM25 检索 cache-first")
|
||||
want := []string{"bm25", "检", "索", "cache", "first"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("Tokens() = %#v, want %#v", got, want)
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"BM25 检索 cache-first", []string{"bm25", "检索", "cache", "first"}},
|
||||
{"数据库迁移", []string{"数据", "据库", "库迁", "迁移"}},
|
||||
{"库", []string{"库"}},
|
||||
{"用pnpm装依赖", []string{"用", "pnpm", "装依", "依赖"}},
|
||||
} {
|
||||
got := Tokens(tc.in)
|
||||
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||
t.Fatalf("Tokens(%q) = %#v, want %#v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user