docs(comments): 注释中文化 · internal/runtime/memory(31 文件,纯替换)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 只翻注释/文档字符串:820 增 / 820 删 严格 1:1;剥掉整行注释与行尾注释尾巴后,
  逐文件与 HEAD 比对「代码部分差异 = 0」;裁决/design 编号计数逐项与 HEAD 一致
- 保留可执行 SQL 样例、SQLite 报错原文、pgvector/HNSW/ANN/BLOB 等技术词原文
- 复核:go build 绿 + memory/projectindex/vector 三包测试绿;gofmt 仅剩 2 个既有未格式化文件(改动前即如此,未顺手修)
This commit is contained in:
AskaEth committed 2026-10-04 13:32:54 +08:00
1 parent eb1828ee7a
commit 61cf80d5ee
31 files changed
+820 -820

No files matched your search

+7 -7
View File
@@ -5,14 +5,14 @@ import (
"time"
)
// decayFactor computes the decay multiplier for a single item: the stored
// decay_weight scaled by a time-decay that halves every recencyHalfLife days
// (design §2 衰减: 久未使用的记忆降权). A freshly touched item keeps its full
// decay_weight; an item unused for N half-lives is scaled to
// decay_weight * 0.5^N, so it ranks progressively lower unless it stays relevant.
// decayFactor 计算单条条目的衰减乘数:已存的
// decay_weight 乘以每 recencyHalfLife 天减半的时间衰减
// (design §2 衰减: 久未使用的记忆降权)。刚被触碰的条目保留完整的
// decay_weight;N 个半衰期未使用的条目被缩放到
// decay_weight * 0.5^N,因此除非持续相关,其排名会逐步下降。
//
// age is measured from UpdatedAt; a future timestamp (clock skew) is clamped to
// zero, so a just-recorded item is never scored above its base weight.
// age 自 UpdatedAt 起算;未来时间戳(时钟偏移)被夹到
// 零,因此刚记录的条目得分绝不会高于其基础权重。
func (r *Retriever) decayFactor(it MemoryItem, now time.Time) float64 {
age := now.Sub(it.UpdatedAt)
if age < 0 {
+8 -8
View File
@@ -19,12 +19,12 @@ func TestRetriever_DecayFactor_久未使用降权(t *testing.T) {
want float64
}{
{name: "fresh keeps full weight", updatedAt: now, decayWeight: 1.0, want: 1.0},
// Default half-life is 30 days: 60 days ago = 2 half-lives = 2^-2.
// 默认半衰期为 30 天:60 天前 = 2 个半衰期 = 2^-2。
{name: "two half-lives halves twice", updatedAt: now.Add(-60 * 24 * time.Hour), decayWeight: 1.0, want: 0.25},
// Lower base decay_weight scales the whole curve down.
// 较低的基础 decay_weight 会整体压低曲线。
{name: "low decay_weight scales down", updatedAt: now, decayWeight: 0.5, want: 0.5},
{name: "low weight plus age compounds", updatedAt: now.Add(-60 * 24 * time.Hour), decayWeight: 0.5, want: 0.125},
// A future timestamp must not boost above the base weight.
// 未来时间戳不得把得分抬到基础权重以上。
{name: "future timestamp clamps to zero", updatedAt: now.Add(time.Hour), decayWeight: 1.0, want: 1.0},
}
for _, tt := range tests {
@@ -48,21 +48,21 @@ func TestRetriever_Retrieve_DecayWeight_降权(t *testing.T) {
s := newTestStore(t)
now := time.Now().UTC()
// A fresh item but with a near-zero decay_weight (design: 用户可削弱某记忆).
// 一条新鲜但 decay_weight 近乎零的条目(design:用户可削弱某记忆)。
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "低权重但新鲜", UpdatedAt: now,
DecayWeight: 0.1, Scope: ScopePersona, Kind: KindWorldEvent,
})
// A slightly older item with a full decay_weight.
// 稍有历史、但 decay_weight 满额的条目。
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "权重高稍旧", UpdatedAt: now.Add(-time.Hour),
DecayWeight: 1.0, Scope: ScopePersona, Kind: KindWorldEvent,
})
r := NewRetriever(s)
// A blank query matches nothing, so ordering is driven by the decay factor
// (decay_weight × time decay) alone: the low-weight fresh item must fall
// below the full-weight slightly-older item.
// query 为空时无任何命中,因此排序完全由衰减因子
// (decay_weight × 时间衰减)驱动:低权重的新条目必须
// 排在满权重、稍旧的条目之后。
results, err := r.Retrieve(ctx, "persona.a", "", 10)
if err != nil {
t.Fatalf("Retrieve: %v", err)
+26 -26
View File
@@ -9,47 +9,47 @@ import (
)
var (
// ErrNilAdapter is returned when an LLMExtractor is built or used without a
// model.Adapter to drive extraction.
// ErrNilAdapter 在构建或使用 LLMExtractor 却未提供
// 驱动提取的 model.Adapter 时返回。
ErrNilAdapter = errors.New("memory: nil model adapter")
)
// Extractor derives candidate memory items from a conversation (design §1).
// Extractor 从一次对话中推导候选记忆条目(design §1)。
//
// Two implementations exist:
// 有两个实现:
//
// - LLMExtractor: asks a model.Adapter to denoise and deduplicate each turn,
// running synchronously (Extract) or on a background goroutine
// (ExtractAsync).
// - ManualExtractor: deterministic, rule-based — it only records what the user
// explicitly asks to remember ("记住 X").
// - LLMExtractor:让 model.Adapter 对每轮对话去噪去重,
// 同步运行(Extract)或在后台 goroutine 上运行
// (ExtractAsync)。
// - ManualExtractor:确定性、规则式 —— 只记录用户
// 显式要求记住的内容(「记住 X」)。
type Extractor interface {
// Extract returns memory items worth persisting from conversation. ctx
// controls cancellation; a canceled context returns an error.
// Extract 返回对话中值得持久化的记忆条目。ctx
// 控制取消;ctx 被取消时返回错误。
Extract(ctx context.Context, conversation []model.Message) ([]MemoryItem, error)
}
// ManualExtractor implements the manual "记住 X" extraction path without an LLM.
// It scans user turns for a leading remember directive and produces a memory
// item for each, falling back to no item for ordinary dialogue. It is the
// deterministic counterpart to the M4 LLM extractor.
// ManualExtractor 实现不经 LLM 的手动「记住 X」提取路径。
// 它扫描用户轮次中领起的记住指令,为每条产出一个记忆
// 条目,普通对话则不产出。它是 M4 LLM 提取器的
// 确定性对照实现。
type ManualExtractor struct {
personaID string
roleID string
}
// NewManualExtractor builds a ManualExtractor whose extracted items carry
// personaID/roleID as their author. Like the LLM extractor, the persona is a
// default rather than the authority: a multi-role caller builds it with a blank
// persona and stamps each item with the turn's role when persisting it
// (design/22 §4.2), so pass "" for a role-agnostic extractor.
// NewManualExtractor 构建 ManualExtractor,其提取出的条目以
// personaID/roleID 为作者。与 LLM 提取器一样,角色是
// 默认值而非权威:多角色调用方以空
// 角色构建它,并在持久化时用该轮的角色给条目盖章
// (design/22 §4.2),因此要构建与角色无关的提取器就传 ""。
func NewManualExtractor(personaID, roleID string) *ManualExtractor {
return &ManualExtractor{personaID: personaID, roleID: roleID}
}
// Extract returns one memory item (kind relationship, persona scope) per user
// turn that asks the agent to remember something. A leading "记住"/"请记住"
// directive is stripped from the stored content; turns without one are skipped.
// Extract 为每个要求代理记住点什么的用户
// 轮次返回一条记忆条目(kind 为 relationship、作用域为 persona)。领起的
// 「记住」/「请记住」指令会从落库内容中剥离;没有该指令的轮次跳过。
func (e *ManualExtractor) Extract(ctx context.Context, conversation []model.Message) ([]MemoryItem, error) {
if err := ctx.Err(); err != nil {
return nil, err
@@ -77,9 +77,9 @@ func (e *ManualExtractor) Extract(ctx context.Context, conversation []model.Mess
return items, nil
}
// rememberDirective strips the "记住" / "请记住" prefix when text is a remember
// instruction. It returns the remembered content and true, or ok=false when the
// text is not an explicit remember request.
// rememberDirective 在 text 是记住指令时剥离「记住」/「请记住」前缀。
// 它返回被记住的内容与 true;text 不是显式记住请求时
// ok=false。
func rememberDirective(text string) (string, bool) {
for _, prefix := range []string{"请记住", "记住"} {
if strings.HasPrefix(text, prefix) {
+4 -4
View File
@@ -15,7 +15,7 @@ func TestManualExtractor_Extract_ParseRemember_正常(t *testing.T) {
conversation := []model.Message{
model.TextMessage(model.RoleUser, "记住她喜欢喝冰美式"),
model.TextMessage(model.RoleAssistant, "好的,我记住了"),
model.TextMessage(model.RoleUser, "今天天气不错"), // no directive
model.TextMessage(model.RoleUser, "今天天气不错"), // 无指令
model.TextMessage(model.RoleUser, "请记住周三下午有项目会"),
}
@@ -44,7 +44,7 @@ func TestManualExtractor_Extract_NoRemember_正常(t *testing.T) {
conversation := []model.Message{
model.TextMessage(model.RoleUser, "今天天气不错"),
model.TextMessage(model.RoleUser, "记得带伞吗"),
model.TextMessage(model.RoleAssistant, "记住是记忆"), // non-user role skipped
model.TextMessage(model.RoleAssistant, "记住是记忆"), // 非用户角色跳过
}
items, err := ex.Extract(context.Background(), conversation)
@@ -77,8 +77,8 @@ func TestStore_RememberManual_正常(t *testing.T) {
if err != nil {
t.Fatalf("RememberManual: %v", err)
}
// The stored content keeps the directive wording; it's a manual single-item
// path, distinct from the extractor which strips the directive.
// 落库内容保留指令原文;这是手动单条目
// 路径,与会剥离指令的提取器不同。
if item.Content != "记住她喜欢喝咖啡" {
t.Fatalf("RememberManual content = %q, want the passed text", item.Content)
}
+54 -54
View File
@@ -10,9 +10,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// extractSystemPrompt tells the model.Adapter how to produce candidate memories:
// a JSON array of objects, one per memory worth persisting. Format is locked so
// parseCandidates can unmarshal it deterministically. 溯源(source_conversation)
// extractSystemPrompt 告诉 model.Adapter 如何产出候选记忆:
// 一个 JSON 对象数组,每个对象一条值得持久化的记忆。格式固定,
// 以便 parseCandidates 确定性地反序列化。溯源(source_conversation)
// 刻意不在其中:它由持久化层盖真实 session/message id,绝不由模型生成
// (裁决 04.4.3,见 source.go)。
const extractSystemPrompt = `你是记忆提取器。从对话中提取值得长期记住的记忆项(用户偏好、习惯、约定、重要事件)。
@@ -20,9 +20,9 @@ const extractSystemPrompt = `你是记忆提取器。从对话中提取值得长
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息。`
// llmCandidate is the wire shape the extractor asks the LLM to emit: one JSON
// object per candidate memory. Scope/Kind default to the extractor's configured
// values when the model does not provide a valid one. 不含 source_conversation:
// llmCandidate 是提取器要求 LLM 输出的线上形状:每条候选
// 记忆一个 JSON 对象。模型未给出合法值时,Scope/Kind 取提取器配置的
// 默认值。不含 source_conversation:
// 模型即使回填该键也会被忽略(结构化溯源只来自持久化层)。
type llmCandidate struct {
Content string `json:"content"`
@@ -33,14 +33,14 @@ type llmCandidate struct {
ProjectID string `json:"project_id"`
}
// LLMExtractor implements Extractor by asking a model.Adapter to derive candidate
// memory items from a conversation (design §1 LLM 异步自动提取). It runs the LLM
// call synchronously in Extract and on a background goroutine in ExtractAsync.
// LLMExtractor 让 model.Adapter 从对话中推导候选记忆条目
// 来实现 Extractor(design §1 LLM 异步自动提取)。它把 LLM
// 调用在 Extract 中同步执行,在 ExtractAsync 中放到后台 goroutine 执行。
//
// It is idempotent and safe for concurrent use: candidate content is normalized
// and deduplicated against a mutex-guarded seen set, so re-extracting the same
// memory never yields a second copy, and the same content appearing twice inside
// one conversation collapses to a single item.
// 它是幂等且并发安全的:候选内容会规范化,
// 并在互斥锁保护的 seen 集合上去重,因此对同一条
// 记忆重复提取不会产生第二份,同一对话中两次出现的
// 相同内容会合并为一条。
type LLMExtractor struct {
adapter model.Adapter
personaID string
@@ -52,15 +52,15 @@ type LLMExtractor struct {
seen map[string]struct{}
}
// NewLLMExtractor builds an LLMExtractor whose extracted items default to
// ScopePersona + KindRelationship when the model does not specify otherwise, and
// carry personaID/roleID as their author. The persona is a *default*, not the
// authority: a multi-role caller (the runtime loop, design/22 §4.2) builds the
// extractor with a blank persona and stamps each item with the role that produced
// the turn, so pass "" for a role-agnostic extractor. A nil adapter is rejected at
// use time via ErrNilAdapter, matching the lazy construction pattern of the rest
// of the runtime; callers may pass nil to build the struct and wire the adapter
// later.
// NewLLMExtractor 构建 LLMExtractor,当模型未另行指定时,其提取出的条目默认为
// ScopePersona + KindRelationship,并以
// personaID/roleID 为作者。角色是*默认值*,不是
// 权威:多角色调用方(运行时循环,design/22 §4.2)以空
// 角色构建提取器,并在持久化时用产出该轮对话的角色给每条条目
// 盖章,因此要构建与角色无关的提取器就传 ""。nil adapter 在
// 使用期经 ErrNilAdapter 拒绝,与运行时其余部分的
// 惰性构造模式一致;调用方可先传 nil 构建结构体、之后再接上
// adapter。
func NewLLMExtractor(adapter model.Adapter, personaID, roleID string) *LLMExtractor {
return &LLMExtractor{
adapter: adapter,
@@ -72,10 +72,10 @@ func NewLLMExtractor(adapter model.Adapter, personaID, roleID string) *LLMExtrac
}
}
// Extract returns the candidate memory items the adapter derives from
// conversation. A nil adapter returns ErrNilAdapter; a canceled context aborts
// before the LLM call. The response is parsed as a JSON array and then
// deduplicated, so items already extracted by this extractor are dropped.
// Extract 返回 adapter 从 conversation 推导出的候选记忆条目。
// nil adapter 返回 ErrNilAdapter;ctx 被取消会在 LLM 调用前
// 中止。响应按 JSON 数组解析,然后
// 去重,因此本提取器已提取过的条目会被丢弃。
func (e *LLMExtractor) Extract(ctx context.Context, conversation []model.Message) ([]MemoryItem, error) {
if e.adapter == nil {
return nil, ErrNilAdapter
@@ -97,19 +97,19 @@ func (e *LLMExtractor) Extract(ctx context.Context, conversation []model.Message
return e.dedupeAndBuild(cands), nil
}
// AsyncResult is the outcome of an ExtractAsync call: either candidate items or
// the error that aborted extraction.
// AsyncResult 是一次 ExtractAsync 调用的结果:候选条目
// 或中止提取的错误。
type AsyncResult struct {
Items []MemoryItem
Err error
}
// ExtractAsync submits the conversation to a background goroutine and returns a
// channel that delivers exactly one AsyncResult when extraction finishes, closed
// by the goroutine. A nil adapter returns ErrNilAdapter immediately. If ctx is
// canceled before the goroutine runs, the result carries the cancellation error.
// Because Extract is idempotent, repeated async extraction of the same memory is
// safe and never double-records.
// ExtractAsync 把对话提交给后台 goroutine,并返回一个
// channel:提取完成时投递恰好一条 AsyncResult,随后由该 goroutine
// 关闭。nil adapter 立即返回 ErrNilAdapter。若 ctx 在 goroutine 运行前
// 被取消,结果携带取消错误。
// 由于 Extract 幂等,对同一条记忆重复异步提取是
// 安全的,绝不会双写。
func (e *LLMExtractor) ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan AsyncResult, error) {
if e.adapter == nil {
return nil, ErrNilAdapter
@@ -127,8 +127,8 @@ func (e *LLMExtractor) ExtractAsync(ctx context.Context, conversation []model.Me
return out, nil
}
// buildExtractionPrompt turns the conversation into the model request messages:
// the system instruction plus one user message carrying the transcript.
// buildExtractionPrompt 把对话转成模型请求消息:
// 系统指令,外加一条携带对话记录的用户消息。
func buildExtractionPrompt(conversation []model.Message) []model.Message {
var b strings.Builder
for _, m := range conversation {
@@ -143,10 +143,10 @@ func buildExtractionPrompt(conversation []model.Message) []model.Message {
}
}
// parseCandidates decodes the adapter's text response into candidates. A fenced
// ```json block is unwrapped so the model may wrap its array in markdown. Blank
// or non-array content yields no candidates; malformed JSON is an error so the
// caller can decide whether to retry or fall back.
// parseCandidates 把 adapter 的文本响应解码为候选。被围栏包裹的
// ```json 块会被剥离,模型因此可以用 markdown 包住数组。空
// 或非数组内容不产出候选;JSON 格式错误则报错,
// 由调用方决定重试还是回退。
func parseCandidates(s string) ([]llmCandidate, error) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
@@ -172,17 +172,17 @@ func parseCandidates(s string) ([]llmCandidate, error) {
return cands, nil
}
// dedupeAndBuild converts valid candidates to MemoryItems, dropping blank content
// and any content already emitted by this extractor (idempotent dedup). It
// requires e.mu; only valid items mark their dedup key so a malformed candidate
// does not suppress a later valid one with the same text.
// dedupeAndBuild 把合法候选转成 MemoryItem,丢弃空内容
// 以及本提取器已产出的内容(幂等去重)。它
// 要求持有 e.mu;只有合法条目才标记其去重键,因此格式错误的候选
// 不会压掉之后内容相同的合法候选。
//
// The persona is NOT required here even though MemoryItem.validate demands one: a
// role-agnostic extractor (personaID == "") is legitimate — the caller that knows
// the turn's role (the loop) stamps the persona when it persists the item — and
// rejecting the item here would silently drop every memory of a multi-role
// process. Content/scope/kind, which only this package can judge, are still
// enforced. SourceConversation 同理刻意留空:由提取调用点(持久化层)
// 这里不要求 persona,尽管 MemoryItem.validate 要求一个:
// 与角色无关的提取器(personaID == "")是合法的 —— 知道
// 该轮角色的调用方(循环)在持久化条目时盖上角色 —— 而
// 在此拒绝条目会静默丢掉多角色
// 进程的每一条记忆。只有本包能判断的
// content/scope/kind 仍会施加。SourceConversation 同理刻意留空:由提取调用点(持久化层)
// 盖真实 session/message id(裁决 04.4.3,见 source.go)。
func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
e.mu.Lock()
@@ -217,13 +217,13 @@ func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
return out
}
// dedupeKey normalizes content for idempotent dedup: case-folded and whitespace-
// collapsed, so "记住 A" and "记住 a" collide into one memory.
// dedupeKey 为幂等去重规范化内容:大小写折叠并
// 压缩空白,因此「记住 A」与「记住 a」会归并为同一条记忆。
func dedupeKey(content string) string {
return strings.Join(strings.Fields(strings.ToLower(content)), " ")
}
// normalizeScope returns s when it names a defined scope, otherwise fallback.
// normalizeScope 在 s 命名了已定义作用域时返回 s,否则返回 fallback。
func normalizeScope(s, fallback string) string {
if Scope(s).valid() {
return s
@@ -231,7 +231,7 @@ func normalizeScope(s, fallback string) string {
return fallback
}
// normalizeKind returns s when it names a defined kind, otherwise fallback.
// normalizeKind 在 s 命名了已定义 kind 时返回 s,否则返回 fallback。
func normalizeKind(s, fallback string) string {
if Kind(s).valid() {
return s
+9 -9
View File
@@ -10,9 +10,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// mockAdapter is a scriptable model.Adapter for tests. With no fn it returns an
// empty candidate array, so a test that only needs a successful call can use it
// directly.
// mockAdapter 是测试用的可脚本化 model.Adapter。未设置 fn 时它返回
// 空候选数组,因此只需要一次成功调用的测试可以直接
// 用它。
type mockAdapter struct {
fn func(ctx context.Context, req model.Request, opts model.Options) (model.Response, error)
}
@@ -81,7 +81,7 @@ func TestLLMExtractor_Extract_InvalidScopeKind_Fallback_正常(t *testing.T) {
if len(items) != 1 {
t.Fatalf("got %d items, want 1", len(items))
}
// Bogus scope/kind fall back to the extractor's configured defaults.
// 非法 scope/kind 回退到提取器配置的默认值。
if items[0].Scope != ScopePersona || items[0].Kind != KindRelationship {
t.Errorf("fallback scope/kind = %q/%q, want persona/relationship", items[0].Scope, items[0].Kind)
}
@@ -99,8 +99,8 @@ func TestLLMExtractor_Prompt_ExcludesSource(t *testing.T) {
func TestLLMExtractor_Extract_Dedup_正常(t *testing.T) {
t.Parallel()
// The model emits the same memory three times, differing only in case and
// surrounding whitespace; they must collapse into one item.
// 模型三次输出同一条记忆,只有大小写与
// 周边空白不同;它们必须归并为一条条目。
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
return model.Response{Content: `[
{"content":"记得喝咖啡"},
@@ -196,8 +196,8 @@ func TestLLMExtractor_ExtractAsync_异步_正常(t *testing.T) {
if err != nil {
t.Fatalf("ExtractAsync: %v", err)
}
// ExtractAsync returned while the adapter is still blocked on release,
// proving the extraction runs on a background goroutine.
// adapter 仍阻塞在 release 上时 ExtractAsync 就返回了,
// 证明提取跑在后台 goroutine 上。
select {
case <-started:
case <-time.After(2 * time.Second):
@@ -231,7 +231,7 @@ func TestLLMExtractor_ExtractAsync_Idempotent_正常(t *testing.T) {
t.Fatalf("first async result = %+v, want 1 item", res)
}
// A second async extraction of the same memory deduplicates to zero.
// 对同一条记忆的第二次异步提取去重后为零条。
second, err := ex.ExtractAsync(context.Background(), conv)
if err != nil {
t.Fatalf("ExtractAsync second: %v", err)
+4 -4
View File
@@ -9,13 +9,13 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// testLogger returns a logger that writes nowhere, keeping test output clean.
// testLogger 返回一个不写任何地方的 logger,保持测试输出干净。
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// newTestStore opens an isolated in-memory SQLite DB and builds a Store over it.
// Each call owns a private DB, so parallel tests are isolated by construction.
// newTestStore 打开一个隔离的内存 SQLite 库并在其上构建 Store。
// 每次调用都拥有私有 DB,因此并行测试天生相互隔离。
func newTestStore(t *testing.T) *Store {
t.Helper()
ctx := context.Background()
@@ -32,7 +32,7 @@ func newTestStore(t *testing.T) *Store {
return s
}
// baseItem is a minimal valid memory item for tests.
// baseItem 是测试用的最小合法记忆条目。
func baseItem() MemoryItem {
return MemoryItem{
PersonaID: "persona.a",
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// messageText returns the concatenated plain-text payload of a message. Image
// parts are skipped; text parts are joined with a single space. It is the
// shared text view used by the working-memory summary and the manual extractor.
// messageText 返回一条消息拼接后的纯文本载荷。图片
// 部分跳过;文本部分以单个空格连接。它是
// 工作记忆摘要与手动提取器共用的文本视图。
func messageText(m model.Message) string {
var b strings.Builder
for _, part := range m.Content {
+15 -15
View File
@@ -7,10 +7,10 @@ import (
"time"
)
// Chapter is a project chapter (layer 3 of the hierarchy): the deep content a
// project is split into. ProjectID links it to the owning project. The zero
// value is not a valid chapter: AddChapter requires a non-blank title and
// content, and generates the id.
// Chapter 是一个项目章节(层级第 3 层):项目被切分成的
// 深层内容。ProjectID 把它链到所属项目。零值
// 不是合法章节:AddChapter 要求 title 与
// content 非空,并生成 id。
type Chapter struct {
ID string
ProjectID string
@@ -20,11 +20,11 @@ type Chapter struct {
UpdatedAt time.Time
}
// AddChapter persists ch under projectID, assigning an id and missing
// timestamps. It requires a non-blank projectID, title and content; a blank id
// is generated. The project row itself does not have to exist, so chapters may
// be added before or after their parent project, but DeleteProject cascades
// them away together.
// AddChapter 以 projectID 持久化 ch,分配 id 与缺失的
// 时间戳。它要求 projectID、title 与 content 非空;id 为空时
// 自动生成。项目行本身不必存在,因此章节可以在其父项目
// 之前或之后添加,但 DeleteProject 会把它们一并级联
// 删除。
func (s *Store) AddChapter(ctx context.Context, projectID string, ch Chapter) error {
if strings.TrimSpace(projectID) == "" {
return ErrMissingProject
@@ -61,11 +61,11 @@ func (s *Store) AddChapter(ctx context.Context, projectID string, ch Chapter) er
return nil
}
// ListChapters returns every chapter for projectID in insertion order
// (created_at ASC, rowid ASC) — rowid breaks created_at ties so chapters written
// within the same clock tick keep their insertion order. It returns an empty
// (non-nil) slice when there are none; a blank projectID returns
// ErrMissingProject.
// ListChapters 按插入顺序(created_at ASC、rowid ASC)返回 projectID 的每个
// 章节 —— rowid 打破 created_at 并列,使同一时钟刻内写入的章节
// 保持其插入顺序。没有条目时返回空的
// (非 nil)切片;projectID 为空返回
// ErrMissingProject。
func (s *Store) ListChapters(ctx context.Context, projectID string) ([]Chapter, error) {
if strings.TrimSpace(projectID) == "" {
return nil, ErrMissingProject
@@ -92,7 +92,7 @@ func (s *Store) ListChapters(ctx context.Context, projectID string) ([]Chapter,
return chapters, nil
}
// scanChapter reads one project_chapter row.
// scanChapter 读取一行 project_chapter。
func scanChapter(sc rowScanner) (Chapter, error) {
var (
ch Chapter
+18 -18
View File
@@ -11,10 +11,10 @@ import (
"time"
)
// Project is a project index entry (design §3): the project marker that links
// its chapters, together with the project's description. It is layer 1 (index
// entry) and layer 2 (description) of the hierarchy. The zero value is not a
// valid project: ID and Title are required.
// Project 是一个项目索引条目(design §3):链接其
// 各章节的项目标示,连同项目描述。它是层级的第 1 层(索引
// 条目)与第 2 层(描述)。零值不是
// 合法项目:ID 与 Title 必填。
type Project struct {
ID string
Title string
@@ -23,10 +23,10 @@ type Project struct {
UpdatedAt time.Time
}
// PutProject upserts p by id. On an existing id it updates title, desc, and
// updated_at while preserving the original created_at; on a new id it assigns
// missing timestamps and inserts. A blank id or title returns ErrMissingProject
// or ErrMissingTitle.
// PutProject 按 id upsert p。id 已存在时更新 title、desc 与
// updated_at,并保留原始的 created_at;id 为新值时
// 补齐缺失时间戳并插入。id 或 title 为空返回 ErrMissingProject
// 或 ErrMissingTitle。
func (s *Store) PutProject(ctx context.Context, p Project) error {
if strings.TrimSpace(p.ID) == "" {
return ErrMissingProject
@@ -55,8 +55,8 @@ func (s *Store) PutProject(ctx context.Context, p Project) error {
return nil
}
// GetProject returns the project with the given id. It returns ErrProjectNotFound
// when the id is blank or does not exist.
// GetProject 返回给定 id 的项目。id 为空或
// 不存在时返回 ErrProjectNotFound。
func (s *Store) GetProject(ctx context.Context, id string) (*Project, error) {
if strings.TrimSpace(id) == "" {
return nil, ErrProjectNotFound
@@ -73,10 +73,10 @@ func (s *Store) GetProject(ctx context.Context, id string) (*Project, error) {
return p, nil
}
// DeleteProject removes the project by id and, as a cascade, every chapter that
// references it. It returns ErrProjectNotFound when the id is blank or no
// project row was removed. The project removal and the chapter removal happen in
// one transaction so a failure never leaves an orphaned project or chapter.
// DeleteProject 按 id 删除项目,并级联删除引用它的每个
// 章节。id 为空、或没有删除任何
// 项目行时返回 ErrProjectNotFound。删项目与删章节发生在
// 同一事务内,因此失败绝不会留下孤儿项目或章节。
func (s *Store) DeleteProject(ctx context.Context, id string) error {
if strings.TrimSpace(id) == "" {
return ErrProjectNotFound
@@ -113,7 +113,7 @@ func (s *Store) DeleteProject(ctx context.Context, id string) error {
return nil
}
// scanProject reads one project row.
// scanProject 读取一行 project。
func scanProject(sc rowScanner) (*Project, error) {
var (
p Project
@@ -132,9 +132,9 @@ func scanProject(sc rowScanner) (*Project, error) {
return &p, nil
}
// newID returns a 128-bit cryptographically random id, hex-encoded. It avoids a
// UUID dependency so the package stays self-contained. rand.Read fails only if
// the platform entropy source is broken, so the error is propagated.
// newID 返回一个 128 位密码学随机 id,十六进制编码。它避开
// UUID 依赖,使本包保持自包含。rand.Read 只在
// 平台熵源损坏时失败,因此错误向上传播。
func newID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
@@ -14,13 +14,13 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// testLogger returns a logger that writes nowhere, keeping test output clean.
// testLogger 返回一个不写任何地方的 logger,保持测试输出干净。
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// newTestStore opens an isolated in-memory SQLite DB and builds a Store over it.
// Each call owns a private DB, so parallel tests are isolated by construction.
// newTestStore 打开一个隔离的内存 SQLite 库并在其上构建 Store。
// 每次调用都拥有私有 DB,因此并行测试天生相互隔离。
func newTestStore(t *testing.T) *Store {
t.Helper()
ctx := context.Background()
@@ -37,7 +37,7 @@ func newTestStore(t *testing.T) *Store {
return s
}
// baseProject is a minimal valid project for tests.
// baseProject 是测试用的最小合法项目。
func baseProject() Project {
return Project{ID: "proj.1", Title: "Cyrene", Desc: "陪伴型 AI 助手"}
}
@@ -90,7 +90,7 @@ func TestStore_PutGetProject_Upsert(t *testing.T) {
if err := s.PutProject(ctx, Project{ID: "proj.1", Title: "T1", Desc: "D1", CreatedAt: oldTime}); err != nil {
t.Fatalf("PutProject insert: %v", err)
}
// Upsert preserves the original created_at while updating title/desc.
// upsert 保留原始的 created_at,同时更新 title/desc。
if err := s.PutProject(ctx, Project{ID: "proj.1", Title: "T2", Desc: "D2", CreatedAt: oldTime.Add(time.Hour)}); err != nil {
t.Fatalf("PutProject update: %v", err)
}
@@ -184,7 +184,7 @@ func TestStore_AddListChapters_正常(t *testing.T) {
t.Errorf("AddChapter did not assign timestamps")
}
// A different project must not see these chapters.
// 另一个项目必须看不到这些章节。
other, err := s.ListChapters(ctx, "proj.other")
if err != nil {
t.Fatalf("ListChapters(proj.other): %v", err)
@@ -397,7 +397,7 @@ func TestStore_Search_SQLInjectionSafe(t *testing.T) {
t.Fatalf("AddChapter malicious: %v", err)
}
// A keyword full of SQL metacharacters must not alter behavior or data.
// 满是 SQL 元字符的关键词不得改变行为或数据。
res, err := s.Search(ctx, maliciousID, "' OR 1=1 --")
if err != nil {
t.Fatalf("Search malicious keyword: %v", err)
@@ -412,7 +412,7 @@ func TestStore_Search_SQLInjectionSafe(t *testing.T) {
t.Fatalf("Search benign keyword on malicious id: %v", err)
}
// The malicious id must round-trip intact (proves parameterized lookup).
// 恶意的 id 必须原样往返(证明查询是参数化的)。
got, err := s.GetProject(ctx, maliciousID)
if err != nil {
t.Fatalf("GetProject malicious: %v", err)
@@ -420,7 +420,7 @@ func TestStore_Search_SQLInjectionSafe(t *testing.T) {
if got.ID != maliciousID {
t.Errorf("ID = %q, want %q", got.ID, maliciousID)
}
// And the malicious project must still be queryable, not dropped.
// 而恶意的项目必须仍可查询,而不是被丢弃。
if _, err := s.GetProject(ctx, "project"); !errors.Is(err, ErrProjectNotFound) {
t.Fatalf("project table intact check: err = %v, want ErrProjectNotFound", err)
}
@@ -490,8 +490,8 @@ func TestStore_Search_MatchInMultipleLines(t *testing.T) {
t.Errorf("line numbers = [%d %d %d], want [1 2 4]",
hit.Matches[0].LineNumber, hit.Matches[1].LineNumber, hit.Matches[2].LineNumber)
}
// The keyword was lowercase "ai" but the content holds uppercase "AI",
// so the match is case-insensitive while Text preserves the original casing.
// 关键词是小写 "ai" 而内容里是大写 "AI",
// 因此匹配不区分大小写,而 Text 保留原始大小写。
if !strings.Contains(hit.Matches[0].Text, "AI") {
t.Errorf("expected case-insensitive match, Text = %q", hit.Matches[0].Text)
}
+25 -25
View File
@@ -5,42 +5,42 @@ import (
"strings"
)
// contextRadius is the number of lines shown on each side of a matched line in
// LineMatch.Context.
// contextRadius 是 LineMatch.Context 中匹配行每侧的
// 显示行数。
const contextRadius = 2
// LineMatch is a single matched line inside a chapter's content plus its
// surrounding context. LineNumber is 1-based within the chapter's Content.
// LineMatch 是章节内容中的一条匹配行及其
// 周边上下文。LineNumber 在章节的 Content 中以 1 为基。
type LineMatch struct {
LineNumber int
Text string
Context string
}
// ChapterHit is a chapter whose content matched a keyword, with the located
// lines (and their context) that matched.
// ChapterHit 是内容命中关键词的一个章节,附带定位到的
// 匹配行(及其上下文)。
type ChapterHit struct {
Chapter *Chapter
Matches []LineMatch
}
// SearchResult is the hierarchical result of a Search. Project carries the
// matched project index entry + description (layer 1/2), set only when the
// keyword hits the project title or desc; Chapters carries the matched chapters
// (layer 3) with located lines. When nothing matches, Project is nil and
// Chapters is empty, with no error.
// SearchResult 是一次 Search 的分层结果。Project 携带命中的
// 项目索引条目 + 描述(第 1/2 层),仅当关键词命中
// 项目标题或 desc 时设置;Chapters 携带命中的章节
// (第 3 层)及定位到的行。无任何命中时,Project 为 nil,
// Chapters 为空,且不报错。
type SearchResult struct {
Project *Project
Chapters []ChapterHit
}
// Search looks up keyword within a single project, walking the hierarchy
// 项目索引 → 项目描述 → 章节内容. It first loads the project (ErrProjectNotFound
// when it does not exist), sets SearchResult.Project when the keyword hits the
// project title or description, then scans every chapter for content hits,
// producing located lines plus their context. A blank keyword returns
// ErrMissingKeyword; a keyword that hits nothing returns a non-nil SearchResult
// with Project and Chapters unset.
// Search 在单个项目内查找关键词,沿层级
// 项目索引 → 项目描述 → 章节内容 遍历。它先加载项目(不存在时返回
// ErrProjectNotFound),当关键词命中
// 项目标题或描述时设置 SearchResult.Project,然后扫描每个章节找内容命中,
// 产出定位到的行及其上下文。关键词为空返回
// ErrMissingKeyword;未命中任何内容的关键词返回非 nil 的 SearchResult,
// 其中 Project 与 Chapters 不设置。
func (s *Store) Search(ctx context.Context, projectID, keyword string) (*SearchResult, error) {
kw := strings.ToLower(strings.TrimSpace(keyword))
if kw == "" {
@@ -69,15 +69,15 @@ func (s *Store) Search(ctx context.Context, projectID, keyword string) (*SearchR
return result, nil
}
// containsFold reports whether s (case-insensitively) contains sub, which must
// already be lowercased by the caller.
// containsFold 报告 s 是否(不区分大小写地)包含 sub,
// sub 必须已由调用方转为小写。
func containsFold(s, sub string) bool {
return strings.Contains(strings.ToLower(s), sub)
}
// findLineMatches returns one LineMatch per content line that (case-insensitively)
// contains keyword. Context is the surrounding contextRadius lines on each side,
// joined by newlines and including the matched line.
// findLineMatches 为每行(不区分大小写地)包含 keyword 的内容返回一个
// LineMatch。Context 是每侧 contextRadius 行的周边上下文,
// 以换行连接,并包含匹配行本身。
func findLineMatches(content, keyword string) []LineMatch {
lines := strings.Split(content, "\n")
matches := make([]LineMatch, 0)
@@ -93,8 +93,8 @@ func findLineMatches(content, keyword string) []LineMatch {
return matches
}
// buildContext returns lines[start..end] around idx (inclusive) joined by \n,
// clamped to the slice bounds so the context never overflows.
// buildContext 返回 idx 周围(含 idx)的 lines[start..end],以 \n 连接,
// 并夹到切片边界内,因此上下文绝不会越界。
func buildContext(lines []string, idx int) string {
start := idx - contextRadius
if start < 0 {
+50 -50
View File
@@ -1,23 +1,23 @@
// Package projectindex implements the project-index hierarchy from
// DevDocs/design/03-记忆.md §3 ("项目记忆"作用域): a project is indexed as an
// entry plus description, and its content is split into chapters that can be
// searched line by line. The three-layer depth is 项目索引 → 项目描述 → 章节内容.
// projectindex 包实现 DevDocs/design/03-记忆.md §3(「项目记忆」作用域)中的
// 项目索引层级:一个项目被索引为一条
// 条目加描述,其内容被切成可逐行检索的章节。
// 三层深度为 项目索引 → 项目描述 → 章节内容。
//
// The two tables model that depth: the project row is the index entry (layer 1)
// carrying its description (layer 2), and project_chapter holds the chapter
// content (layer 3). Project memory is readable and writable by every persona
// (design §3 "人人可读可写", "项目索引各角色可查"), so the store carries no
// persona/role column and never filters by identity here.
// 两张表刻画该深度:project 行是指引条目(第 1 层),
// 携带其描述(第 2 层),project_chapter 保存章节
// 内容(第 3 层)。项目记忆每个角色都可读写
// (design §3「人人可读可写」「项目索引各角色可查」),因此存储没有
// persona/role 列,也不在此按身份过滤。
//
// This package is deliberately separate from the vector index
// (internal/runtime/memory/vector): it does text-based hierarchical indexing of
// project entries, descriptions, and chapters. Semantic/vector search belongs to
// the vector package.
// 本包刻意与向量索引
// (internal/runtime/memory/vector)分离:它对项目条目、描述与章节
// 做基于文本的分层索引。语义/向量检索属于
// 向量包。
//
// The store owns its schema: New creates the project and project_chapter tables
// and the chapter index idempotently. All SQL is parameterized (?), column lists
// are explicit (no SELECT *), reads take a context.Cancel, and every error is
// wrapped with %w so callers can use errors.Is against the package sentinels.
// 存储自己拥有 schema:New 幂等地创建 project 与 project_chapter 表
// 及章节索引。所有 SQL 参数化(?),列清单
// 显式列出(无 SELECT *),读取带 context.Cancel,且每个错误都
// 用 %w 包裹,调用方可用 errors.Is 对包哨兵错误判断。
package projectindex
import (
@@ -31,56 +31,56 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// Sentinels distinguishing projectindex failures. Each is wrapped with %w at the
// call site so callers can use errors.Is.
// 区分 projectindex 各类失败的哨兵错误。每个都在
// 调用点用 %w 包裹,调用方可用 errors.Is 判断。
var (
// ErrProjectNotFound is returned when a project id is blank or does not exist.
// ErrProjectNotFound 在项目 id 为空或不存在时返回。
ErrProjectNotFound = errors.New("projectindex: project not found")
// ErrChapterNotFound is returned when a required chapter reference is invalid.
// ErrChapterNotFound 在必需的章节引用无效时返回。
ErrChapterNotFound = errors.New("projectindex: chapter not found")
// ErrNilDB is returned when New receives a nil *sql.DB.
// ErrNilDB 在 New 收到 nil *sql.DB 时返回。
ErrNilDB = errors.New("projectindex: nil db")
// ErrMissingProject is returned when a required project id is blank.
// ErrMissingProject 在必需的 project id 为空时返回。
ErrMissingProject = errors.New("projectindex: project_id is required")
// ErrMissingTitle is returned when a project or chapter title is blank.
// ErrMissingTitle 在项目或章节标题为空时返回。
ErrMissingTitle = errors.New("projectindex: title is required")
// ErrMissingContent is returned when a chapter has no content.
// ErrMissingContent 在章节没有内容时返回。
ErrMissingContent = errors.New("projectindex: content is required")
// ErrMissingKeyword is returned when Search receives a blank keyword.
// ErrMissingKeyword 在 Search 收到空关键词时返回。
ErrMissingKeyword = errors.New("projectindex: keyword is required")
)
// Store is a project-index store backed by a *sql.DB.
// Store 是基于 *sql.DB 的项目索引存储。
//
// It owns its schema: New creates the project / project_chapter tables and the
// chapter index idempotently. Concurrency is delegated to *sql.DB, which is safe
// for concurrent use by contract, so Store holds no further lock. Every query is
// parameterized and reads take a context.Cancel.
// 它自己拥有 schema:New 幂等地创建 project / project_chapter 表与
// 章节索引。并发交由 *sql.DB,按约定它可安全
// 并发使用,因此 Store 不再持锁。每个查询都
// 参数化,读取带 context.Cancel。
type Store struct {
db *sql.DB
log *slog.Logger
}
// rowScanner is satisfied by both *sql.Row and *sql.Rows so a single scanProject
// or scanChapter helper serves both the Get and List paths.
// rowScanner 同时被 *sql.Row 与 *sql.Rows 满足,因此单个 scanProject
// 或 scanChapter 辅助函数即可服务 Get 与 List 两条路径。
type rowScanner interface {
Scan(dest ...any) error
}
// New builds a Store on db, ensuring the schema exists.
// New 在 db 上构建 Store,确保 schema 存在。
//
// db must be a live connection (the migration framework is owned by
// internal/db). A nil db returns ErrNilDB. log may be nil, in which case a
// discard logger is used; the active logger is tagged with source
// "memory/projectindex" so every record is traceable. ctx controls schema
// creation and must be honored by the caller. Legacy variable-width timestamps
// are normalized in place (see normalizeTimestamps).
// db 必须是存活的连接(迁移框架由
// internal/db 拥有)。nil db 返回 ErrNilDB。log 可为 nil,此时
// 使用丢弃型 logger;实际使用的 logger 带 source
// "memory/projectindex" 标签,因此每条记录都可追溯。ctx 控制 schema
// 创建,调用方必须遵守。旧的变宽时间戳
// 就地规范化(见 normalizeTimestamps)。
func New(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil {
return nil, ErrNilDB
@@ -101,7 +101,7 @@ func New(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
return &Store{db: db, log: log}, nil
}
// discardLogger returns log if non-nil, otherwise a logger that writes nowhere.
// discardLogger 在 log 非 nil 时返回 log,否则返回一个不写任何地方的 logger。
func discardLogger(log *slog.Logger) *slog.Logger {
if log != nil {
return log
@@ -109,12 +109,12 @@ func discardLogger(log *slog.Logger) *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// ListProjects returns every project index entry in insertion order (created_at
// ASC, rowid ASC) — rowid breaks created_at ties so projects written within the
// same clock tick keep their insertion order. An empty (non-nil) slice is
// returned when there are none, so callers can range over it unconditionally. A
// read error is wrapped with %w so callers can use errors.Is against the package
// sentinels.
// ListProjects 按插入顺序(created_at ASC、rowid ASC)返回每个
// 项目索引条目 —— rowid 打破 created_at 并列,使同一时钟刻内写入的项目
// 保持其插入顺序。没有条目时返回空的
// (非 nil)切片,调用方可以无条件 range。读
// 错误用 %w 包裹,调用方可用 errors.Is 对包
// 哨兵错误判断。
func (s *Store) ListProjects(ctx context.Context) ([]Project, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, title, desc, created_at, updated_at
FROM project ORDER BY created_at ASC, rowid ASC`)
@@ -137,9 +137,9 @@ func (s *Store) ListProjects(ctx context.Context) ([]Project, error) {
return projects, nil
}
// Schema DDL, applied idempotently by New. The two tables model the three-layer
// hierarchy: the project row is the index entry (layer 1) plus its description
// (layer 2), and project_chapter holds the chapter content (layer 3).
// schema DDL,由 New 幂等施加。两张表刻画三层
// 层级:project 行是指引条目(第 1 层)加其描述
// (第 2 层),project_chapter 保存章节内容(第 3 层)。
const createProjectTable = `
CREATE TABLE IF NOT EXISTS project (
id TEXT PRIMARY KEY,
+38 -38
View File
@@ -8,35 +8,35 @@ import (
"time"
)
// timeLayout is the fixed-width timestamp layout used for every stored
// created_at/updated_at value. Unlike time.RFC3339Nano it never drops trailing
// fractional zeros, so every value is exactly storedTimeLen bytes wide; SQLite
// compares these TEXT columns as plain strings and equal width is what makes
// that comparison agree with chronological order. UTC values render the zone as
// a single "Z".
// timeLayout 是每个已存 created_at/updated_at 值使用的定宽
// 时间戳布局。与 time.RFC3339Nano 不同,它从不丢弃末尾
// 小数零,因此每个值恰好 storedTimeLen 字节宽;SQLite
// 把这些 TEXT 列按普通字符串比较,而等宽正是让
// 该比较与时间顺序一致的原因。UTC 值把时区渲染为
// 单个 "Z"。
const timeLayout = "2006-01-02T15:04:05.000000000Z07:00"
// storedTimeLen is the byte length of a UTC value formatted with timeLayout:
// "2006-01-02T15:04:05.000000000Z" — the Z07:00 zone collapses to "Z" in UTC.
// storedTimeLen 是用 timeLayout 格式化的 UTC 值的字节长度:
// "2006-01-02T15:04:05.000000000Z" —— Z07:00 时区在 UTC 下坍缩为 "Z"。
const storedTimeLen = 30
// Table names normalized on open; they must match the DDL in store.go.
// 打开时需要规范化的表名;它们必须与 store.go 中的 DDL 一致。
const (
projectTable = "project"
projectChapterTable = "project_chapter"
)
// formatTime encodes t in UTC with the fixed-width timeLayout. Always writing
// UTC keeps ordering and round-trips stable across processes, and the fixed
// width is what lets ORDER BY created_at order these TEXT columns by time.
// formatTime 用定宽 timeLayout 把 t 编码为 UTC。始终写
// UTC 使排序与往返在各进程间保持稳定,而定宽
// 正是 ORDER BY created_at 能按时间排序这些 TEXT 列的原因。
func formatTime(t time.Time) string {
return t.UTC().Format(timeLayout)
}
// parseTime decodes a stored timestamp. time.RFC3339Nano accepts both the
// fixed-width values this package writes and the legacy variable-width ones, so
// the same parser serves reads and the open-time normalization. Blank input
// yields a zero time.
// parseTime 解码一个已存时间戳。time.RFC3339Nano 同时接受
// 本包写入的定宽值与旧的变宽值,因此同一个解析器既服务
// 读取、也服务打开时的规范化。空输入
// 得到零值时间。
func parseTime(s string) (time.Time, error) {
if strings.TrimSpace(s) == "" {
return time.Time{}, nil
@@ -48,15 +48,15 @@ func parseTime(s string) (time.Time, error) {
return t, nil
}
// normalizeTimestamps rewrites every project / project_chapter timestamp that is
// not storedTimeLen bytes wide into the fixed-width timeLayout. Rows written
// before the layout existed carry variable-width RFC3339Nano text whose string
// order is not its time order; this brings them in line.
// normalizeTimestamps 把每个非 storedTimeLen 字节宽的 project /
// project_chapter 时间戳重写为定宽 timeLayout。布局存在之前写入的行
// 携带变宽 RFC3339Nano 文本,其字符串
// 顺序并非时间顺序;这把它们拉齐。
//
// It is idempotent: only rows whose column length differs from storedTimeLen are
// selected, so running it again on a normalized table is a no-op. A row whose
// value does not parse is left exactly as it is — one bad legacy value must not
// keep the store from opening.
// 它是幂等的:只选取列长度与 storedTimeLen 不同的行,
// 因此在已规范化的表上再跑一次是空操作。值解析不了的行
// 原样保留 —— 一个坏的遗留值不该
// 妨碍存储打开。
func normalizeTimestamps(ctx context.Context, db *sql.DB) error {
for _, table := range []string{projectTable, projectChapterTable} {
for _, col := range []string{"created_at", "updated_at"} {
@@ -68,11 +68,11 @@ func normalizeTimestamps(ctx context.Context, db *sql.DB) error {
return nil
}
// normalizeTimeColumn rewrites every table.col value that is not storedTimeLen
// bytes wide. table and col are compile-time constants, never user input, so
// splicing them into the statement is safe; the length comparison and every
// value written are parameterized. All rewrites are collected before any UPDATE
// runs, so no write happens while the SELECT cursor is still open.
// normalizeTimeColumn 重写 table.col 中每个非 storedTimeLen
// 字节宽的值。table 与 col 是编译期常量,绝非用户输入,因此
// 把它们拼进语句是安全的;长度比较与每个
// 写入值都参数化。所有重写都在任何 UPDATE
// 执行前收集完毕,因此 SELECT 游标仍打开时不会发生写入。
func normalizeTimeColumn(ctx context.Context, db *sql.DB, table, col string) error {
fixes, err := collectLegacyTimestamps(ctx, db, table, col)
if err != nil {
@@ -87,8 +87,8 @@ func normalizeTimeColumn(ctx context.Context, db *sql.DB, table, col string) err
return nil
}
// timeFix is one pending timestamp rewrite: which table and column, which row
// (by primary key id), and the fixed-width value to write.
// timeFix 是一条待执行的时间戳重写:哪张表哪一列、哪一行
// (按主键 id),以及要写入的定宽值。
type timeFix struct {
table string
col string
@@ -96,10 +96,10 @@ type timeFix struct {
value string
}
// collectLegacyTimestamps reads the rows of table.col whose value is not
// storedTimeLen bytes wide and returns the parseable ones as pending rewrites.
// Unparseable values are skipped, so normalization can never fail a row: the
// only errors are genuine read failures.
// collectLegacyTimestamps 读取 table.col 中值非 storedTimeLen
// 字节宽的行,把可解析的作为待重写项返回。
// 不可解析的值跳过,因此规范化绝不会让某行失败:
// 唯一的错误是真正的读取失败。
func collectLegacyTimestamps(ctx context.Context, db *sql.DB, table, col string) ([]timeFix, error) {
rows, err := db.QueryContext(ctx,
"SELECT id, "+col+" FROM "+table+" WHERE length("+col+") != ?", storedTimeLen)
@@ -126,9 +126,9 @@ func collectLegacyTimestamps(ctx context.Context, db *sql.DB, table, col string)
return fixes, nil
}
// reformatTime re-encodes raw with timeLayout. The bool is false when raw is
// already storedTimeLen wide or when it does not parse, in which case the caller
// leaves the stored value untouched.
// reformatTime 用 timeLayout 重新编码 raw。raw 已经
// storedTimeLen 宽、或无法解析时 bool 为 false,此时调用方
// 保持存储值不动。
func reformatTime(raw string) (string, bool) {
if len(raw) == storedTimeLen {
return "", false
@@ -6,8 +6,8 @@ import (
"time"
)
// insertLegacyChapterSQL writes a project_chapter row with caller-supplied raw
// timestamps, standing in for a row persisted before the fixed-width layout.
// insertLegacyChapterSQL 用调用方给定的原始时间戳写入一行
// project_chapter,代表定宽布局之前持久化的行。
const insertLegacyChapterSQL = `INSERT INTO project_chapter
(id, project_id, title, content, created_at, updated_at)
VALUES (?, 'proj.1', '旧章节', '旧内容', ?, ?)`
@@ -37,9 +37,9 @@ func TestFormatTime_定宽_正常(t *testing.T) {
}
}
// TestStore_ListChapters_变宽时间戳_排序 is the projectindex regression for the
// variable-width timestamp bug: RFC3339Nano would store these as ":05.83" and
// ":05.8309789", whose text order is the reverse of their time order.
// TestStore_ListChapters_变宽时间戳_排序 是 projectindex 针对变宽
// 时间戳缺陷的回归:RFC3339Nano 会把它们存成 ":05.83" 与
// ":05.8309789",其文本顺序与时间顺序相反。
func TestStore_ListChapters_变宽时间戳_排序(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -49,8 +49,8 @@ func TestStore_ListChapters_变宽时间戳_排序(t *testing.T) {
older := sec.Add(830 * time.Millisecond)
newer := older.Add(978900 * time.Nanosecond)
// The newer chapter is inserted first, so a rowid-only tiebreak would put it
// last: only correctly ordered created_at text places it last-but-one.
// 较新的章节先插入,因此只按 rowid 排序会把它放到
// 最后:只有正确排序的 created_at 文本才会把它放在倒数第二。
if err := s.AddChapter(ctx, "proj.1", Chapter{
Title: "较晚", Content: "x", CreatedAt: newer, UpdatedAt: newer,
}); err != nil {
@@ -83,9 +83,9 @@ func TestStore_ListChapters_变宽时间戳_排序(t *testing.T) {
}
}
// TestStore_ListChapters_同一时间戳_插入序 covers the tie case: consecutive
// AddChapter calls can share one clock tick, so rowid ASC must keep them in
// insertion order.
// TestStore_ListChapters_同一时间戳_插入序 覆盖并列情形:连续的
// AddChapter 调用可能共享同一时钟刻,因此 rowid ASC 必须让它们保持
// 插入顺序。
func TestStore_ListChapters_同一时间戳_插入序(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -115,9 +115,9 @@ func TestStore_ListChapters_同一时间戳_插入序(t *testing.T) {
}
}
// TestNew_归一化历史时间戳_正常 writes legacy variable-width rows into both
// tables, re-opens the store to trigger normalization, and checks the repaired
// text plus idempotence and the unparseable-row skip.
// TestNew_归一化历史时间戳_正常 把旧的变宽数据写入两张
// 表,重新打开存储触发规范化,并检查修复后的文本
// 以及幂等性与无法解析行的跳过。
func TestNew_归一化历史时间戳_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -135,7 +135,7 @@ func TestNew_归一化历史时间戳_正常(t *testing.T) {
t.Fatalf("insert unparseable chapter: %v", err)
}
// Re-opening normalizes both tables; an unparseable value must not block it.
// 重新打开会规范化两张表;无法解析的值不得阻塞它。
if _, err := New(ctx, s.db, testLogger()); err != nil {
t.Fatalf("New (normalize): %v", err)
}
@@ -163,7 +163,7 @@ func TestNew_归一化历史时间戳_正常(t *testing.T) {
t.Errorf("unparseable created_at = %q, want it left as is", broken)
}
// A further open must be a no-op.
// 再次打开必须是空操作。
if _, err := New(ctx, s.db, testLogger()); err != nil {
t.Fatalf("New (second pass): %v", err)
}
+88 -88
View File
@@ -13,54 +13,54 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
)
// Retriever defaults and tuning constants.
// 检索器默认值与可调常量。
const (
// defaultKeywordWeight is the relative weight of keyword (FTS/TF-IDF)
// relevance in the combined score.
// defaultKeywordWeight 是关键词(FTS/TF-IDF)
// 相关性在综合得分中的相对权重。
defaultKeywordWeight = 1.0
// defaultSemanticWeight is the relative weight of semantic-vector relevance
// in the combined score. It only contributes when an embedder is configured.
// defaultSemanticWeight 是语义向量相关性
// 在综合得分中的相对权重。仅在配置了 embedder 时才参与计算。
defaultSemanticWeight = 1.0
// defaultRecencyHalfLifeDays is the number of days over which a memory's
// recency weight halves, used to favor recently touched memories (design §2
// attenuation).
// defaultRecencyHalfLifeDays 是记忆的
// 新近度权重减半所需的天数,用于偏好近期被触碰的记忆(design §2
// 衰减)。
defaultRecencyHalfLifeDays = 30.0
// defaultTopK is the number of results returned when Retrieve or Inject is
// called with a non-positive topK.
// defaultTopK 是 Retrieve 或 Inject 以
// 非正 topK 调用时返回的结果数。
defaultTopK = 10
// hoursPerDay converts a day count into a time.Duration.
// hoursPerDay 把天数换算为 time.Duration。
hoursPerDay = 24
)
// Result is a single retrieval hit: the memory item plus its combined
// relevance × decay score explaining its ranking. Higher is more relevant.
// Result 是一次检索命中:记忆条目,外加解释其排名的
// 相关性 × 衰减综合得分。得分越高越相关。
type Result struct {
Item MemoryItem
Score float64
// SourceConversation is the conversation the item was extracted from, for
// 溯源 (design §2). 裁决 04.4.3 起为结构化 JSON(session_id/message_id,
// 见 ParseSourceConversation),由持久化层盖真实 id。It mirrors
// Item.SourceConversation so callers can trace a hit without reaching into
// Item.
// SourceConversation 是条目提取自哪次会话,用于
// 溯源(design §2)。裁决 04.4.3 起为结构化 JSON(session_id/message_id,
// 见 ParseSourceConversation),由持久化层盖真实 id。它是
// Item.SourceConversation 的镜像,调用方无需深入
// Item 即可回溯一次命中。
SourceConversation string
// Project is the linked project-index entry (设计 §3 项目分层), populated only
// when the retriever is wired with a project-index store and the item is a
// project-scoped memory carrying a ProjectID. It lets callers surface the
// 项目索引 → 项目描述 → 章节内容 chain for a retrieved item.
// Project 是链接的项目索引条目(设计 §3 项目分层),仅当
// 检索器接入了项目索引存储、且该条目是携带 ProjectID 的
// 项目作用域记忆时才填充。它让调用方为一条检索结果
// 呈现「项目索引 → 项目描述 → 章节内容」链路。
Project *projectindex.Project
}
// Retriever ranks the long-term memories visible to a persona — its own items
// plus the shared user/project scopes (design §3/§4, see visibleCandidates) —
// by relevance (keyword and, when an embedder or vector index is configured,
// semantic vectors) weighted by decay (decay_weight × time decay), then returns
// the top K. It is stateless and safe for concurrent use; the ranking is
// recomputed from the store on every call, so results always reflect persisted
// state.
// Retriever 对某角色可见的长期记忆 —— 它自己的条目
// 加共享的用户/项目作用域(design §3/§4,见 visibleCandidates)——
// 按相关性(关键词,以及配置了 embedder 或向量索引时的
// 语义向量)加权衰减(decay_weight × 时间衰减)排序后返回
// 前 K 条。它是无状态的、并发安全;排名在
// 每次调用时从存储重算,因此结果始终反映持久化的
// 状态。
type Retriever struct {
store *Store
embedder embedding.Embedder
@@ -71,43 +71,43 @@ type Retriever struct {
recencyHalfLife time.Duration
}
// RetrieverOption customizes a Retriever built by NewRetriever.
// RetrieverOption 定制由 NewRetriever 构建的 Retriever。
type RetrieverOption func(*Retriever)
// WithEmbedder sets the semantic embedder used for vector similarity computed
// in-memory. When nil (the default), retrieval falls back to keyword matching
// plus decay only. When a vector index is also wired via WithVectorIndex, the
// index takes precedence: it is the persistent semantic path (design §3
// 可选增强), and WithEmbedder computes similarity on the fly instead.
// WithEmbedder 设置用于内存内向量相似度计算的语义 embedder。
// 为 nil(默认)时,检索退化为仅关键词匹配
// 加衰减。若同时经 WithVectorIndex 接入了向量索引,则
// 索引优先:它是持久化语义路径(design §3
// 可选增强),此时 WithEmbedder 改为即时计算相似度。
func WithEmbedder(e embedding.Embedder) RetrieverOption {
return func(r *Retriever) {
r.embedder = e
}
}
// WithVectorIndex wires a persistent vector.Index into retrieval. When set,
// semantic relevance is answered by idx.Search over the stored vectors rather
// than by in-memory embedding, so a configured pgvector/SQLite vector store is
// genuinely used (design §3 "真正启用向量检索"). When nil (the default), the
// semantic path is skipped unless WithEmbedder is set. The caller is responsible
// for keeping the index in sync with stored items (embed + add on write).
// WithVectorIndex 把持久化的 vector.Index 接入检索。设置后,
// 语义相关性由 idx.Search 在已存向量上作答,而非
// 内存内向量化,因此配置的 pgvector/SQLite 向量存储被
// 真正启用(design §3「真正启用向量检索」)。为 nil(默认)时,
// 除非设置了 WithEmbedder,否则语义路径跳过。调用方负责
// 让索引与已存条目保持同步(写入时 embed + add)。
func WithVectorIndex(idx vector.Index) RetrieverOption {
return func(r *Retriever) {
r.vindex = idx
}
}
// WithProjectIndex wires a projectindex.Store so retrieval can attach the linked
// project-index entry (设计 §3 项目分层) to each project-scoped hit. When nil
// (the default), Result.Project is left unset.
// WithProjectIndex 接入 projectindex.Store,使检索能把链接的
// 项目索引条目(设计 §3 项目分层)附加到每条项目作用域命中上。为 nil
// (默认)时,Result.Project 不设置。
func WithProjectIndex(s *projectindex.Store) RetrieverOption {
return func(r *Retriever) {
r.projectStore = s
}
}
// WithKeywordWeight sets the relative weight of keyword relevance. Non-positive
// values are ignored and the default kept.
// WithKeywordWeight 设置关键词相关性的相对权重。非正值
// 被忽略,保留默认值。
func WithKeywordWeight(w float64) RetrieverOption {
return func(r *Retriever) {
if w > 0 {
@@ -116,8 +116,8 @@ func WithKeywordWeight(w float64) RetrieverOption {
}
}
// WithSemanticWeight sets the relative weight of semantic relevance. Non-positive
// values are ignored and the default kept.
// WithSemanticWeight 设置语义相关性的相对权重。非正值
// 被忽略,保留默认值。
func WithSemanticWeight(w float64) RetrieverOption {
return func(r *Retriever) {
if w > 0 {
@@ -126,8 +126,8 @@ func WithSemanticWeight(w float64) RetrieverOption {
}
}
// WithRecencyHalfLife sets how many days a memory's recency weight takes to
// halve. Non-positive values are ignored and the default kept.
// WithRecencyHalfLife 设置记忆的新近度权重减半所需的天数。
// 非正值被忽略,保留默认值。
func WithRecencyHalfLife(days float64) RetrieverOption {
return func(r *Retriever) {
if days > 0 {
@@ -136,8 +136,8 @@ func WithRecencyHalfLife(days float64) RetrieverOption {
}
}
// NewRetriever builds a Retriever over store. A nil store is invalid and panics
// at call time; callers should pass a non-nil store.
// NewRetriever 在 store 之上构建 Retriever。nil store 非法,会在
// 调用时 panic;调用方应传入非 nil 的 store。
func NewRetriever(store *Store, opts ...RetrieverOption) *Retriever {
r := &Retriever{
store: store,
@@ -151,15 +151,15 @@ func NewRetriever(store *Store, opts ...RetrieverOption) *Retriever {
return r
}
// Retrieve returns up to topK items for personaID that best match query, ranked
// by relevance (keyword + optional semantic) weighted by decay (decay_weight ×
// time decay). 候选集是 design/03 §3/§4 的可见域并集:该角色自己的条目
// Retrieve 为 personaID 返回最匹配 query 的最多 topK 条,按
// 相关性(关键词 + 可选语义)加权衰减(decay_weight ×
// 时间衰减)排序。候选集是 design/03 §3/§4 的可见域并集:该角色自己的条目
// (ListByPersona)∪ 全局共享的用户习惯域(ScopeUser)∪ 人人可读的
// 项目域(ScopeProject),按 id 去重。他角色的角色日常记忆(ScopePersona)
// 绝不进入候选集 —— 跨角色读只走「把 B 当子代理去查 B」的派发机制
// (design/22 §9),直接检索不越权。Query and candidate contents are both
// read under ctx; a canceled context aborts the work. A blank query returns the
// most recently used items (decay order only).
// (design/22 §9),直接检索不越权。query 与候选内容都在
// ctx 下读取;ctx 被取消会中止工作。query 为空时返回
// 最近使用过的条目(仅按衰减顺序)。
func (r *Retriever) Retrieve(ctx context.Context, personaID, query string, topK int) ([]Result, error) {
if topK <= 0 {
topK = defaultTopK
@@ -176,10 +176,10 @@ func (r *Retriever) Retrieve(ctx context.Context, personaID, query string, topK
sem := r.semanticScores(ctx, items, query)
now := time.Now().UTC()
// matched is true when at least one item shares relevance with the query. In
// that mode relevance dominates ranking and recency only attenuates among
// matches; unmatched items get score 0 and sort below. When nothing matches
// (e.g. a blank query), we rank by recency alone.
// matched 为 true 表示至少有一条条目与 query 有相关性。这种
// 模式下相关性主导排名,新近度只在命中项之间
// 衰减;未命中的条目得分为 0 且排在后面。无任何命中
// (例如 query 为空)时,只按新近度排名。
matched := false
relevance := make(map[string]float64, len(items))
for _, it := range items {
@@ -255,10 +255,10 @@ func (r *Retriever) visibleCandidates(ctx context.Context, personaID string) ([]
return items, nil
}
// keywordScores scores each item against query with an in-memory TF-IDF inverted
// index (embedding.Index), reusing the embedding package's tokenizer. Scores are
// normalized to [0,1] by the maximum so they are comparable with the semantic
// scores.
// keywordScores 用内存内 TF-IDF 倒排索引(embedding.Index)为每条条目
// 与 query 打分,复用 embedding 包的分词器。得分
// 以最大值为基准归一化到 [0,1],以便与语义
// 得分可比。
func (r *Retriever) keywordScores(items []MemoryItem, query string) map[string]float64 {
scores := make(map[string]float64, len(items))
index := embedding.NewIndex[string]()
@@ -279,12 +279,12 @@ func (r *Retriever) keywordScores(items []MemoryItem, query string) map[string]f
return scores
}
// semanticScores computes per-item semantic relevance for query. When a vector
// index is wired (WithVectorIndex) it answers from the persistent index via
// idx.Search — the real vector path (design §3). Otherwise, with an embedder
// configured, it embeds query and candidates in-memory and uses clamped cosine
// similarity. With neither it returns an empty map, so semantic relevance
// contributes nothing.
// semanticScores 计算每个条目对 query 的语义相关性。接入了向量
// 索引(WithVectorIndex)时,它经 idx.Search 从持久化索引
// 作答 —— 真正的向量路径(design §3)。否则,在配置了 embedder
// 时,它在内存内对 query 与候选做向量化并使用夹取的余弦
// 相似度。两者都没有时返回空 map,因此语义相关性
// 不贡献任何分数。
func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 {
scores := make(map[string]float64, len(items))
if r.vindex != nil {
@@ -319,11 +319,11 @@ func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, quer
return scores
}
// vectorIndexScores answers semantic relevance from the persistent vector index:
// it embeds the query with the index, searches for the top similar ids, and
// returns their cosine similarity, normalized to [0,1] by the top hit. Because
// idx.Search ranks over all stored ids, an item absent from the index carries no
// semantic score. A blank query or an index error degrades to an empty map.
// vectorIndexScores 从持久化向量索引作答语义相关性:
// 它用索引对 query 做向量化,检索最相似的若干 id,并
// 返回它们的余弦相似度,以最高命中为基准归一化到 [0,1]。由于
// idx.Search 在全部已存 id 上排名,不在索引中的条目没有
// 语义得分。query 为空或索引报错时退化为空 map。
func (r *Retriever) vectorIndexScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 {
scores := make(map[string]float64, len(items))
if strings.TrimSpace(query) == "" {
@@ -348,10 +348,10 @@ func (r *Retriever) vectorIndexScores(ctx context.Context, items []MemoryItem, q
return scores
}
// projectFor returns the project-index entry linked to a project-scoped item, or
// nil when the retriever has no project-index store, the item is not
// project-scoped, or it carries no ProjectID. A load failure is treated as "no
// project" rather than failing the whole retrieval (design §3 项目索引各角色可查).
// projectFor 返回链接到项目作用域条目的项目索引条目;检索器
// 没有项目索引存储、条目不是
// 项目作用域、或条目不带 ProjectID 时返回 nil。加载失败按「无
// 项目」处理,而不是让整次检索失败(design §3 项目索引各角色可查)。
func (r *Retriever) projectFor(ctx context.Context, it MemoryItem) *projectindex.Project {
if r.projectStore == nil || it.Scope != ScopeProject || strings.TrimSpace(it.ProjectID) == "" {
return nil
@@ -363,7 +363,7 @@ func (r *Retriever) projectFor(ctx context.Context, it MemoryItem) *projectindex
return p
}
// keywordText is the text indexed for keyword matching: content plus tags.
// keywordText 是用于关键词匹配的索引文本:content 加 tags。
func keywordText(it MemoryItem) string {
if len(it.Tags) == 0 {
return it.Content
@@ -371,8 +371,8 @@ func keywordText(it MemoryItem) string {
return it.Content + " " + strings.Join(it.Tags, " ")
}
// cosineSim computes the cosine similarity of two vectors, clamped to [0,1] by
// the caller. Zero-length or mismatched vectors yield 0.
// cosineSim 计算两个向量的余弦相似度,由调用方夹到 [0,1]。
// 长度为零或不一致的向量得 0。
func cosineSim(a, b embedding.Vector) float64 {
if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
return 0
@@ -389,10 +389,10 @@ func cosineSim(a, b embedding.Vector) float64 {
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
// Inject retrieves up to topK relevant memories for personaID and formats them
// as a compact context block ready to be injected into the prompt. It returns an
// empty string when nothing is retrieved, so callers can ignore the result when
// there is no relevant memory.
// Inject 为 personaID 检索最多 topK 条相关记忆,并把它们格式化为
// 紧凑的上下文块,可供注入提示词。没有检索到任何内容时
// 返回空字符串,因此没有相关记忆时调用方可以忽略
// 结果。
func (r *Retriever) Inject(ctx context.Context, personaID, query string, topK int) (string, error) {
results, err := r.Retrieve(ctx, personaID, query, topK)
if err != nil {
+15 -15
View File
@@ -9,8 +9,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
)
// mockVectorIndex is a scriptable vector.Index for tests. With no fns it emits
// one-hot vectors and returns no search hits.
// mockVectorIndex 是测试用的可脚本化 vector.Index。未设置 fns 时它
// 发出 one-hot 向量且不返回检索命中。
type mockVectorIndex struct {
dim int
embedFn func(ctx context.Context, texts []string) ([]vector.Vector, error)
@@ -59,9 +59,9 @@ func TestRetriever_Retrieve_WithVectorIndex_正常(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
// Both items are in English while the query is Chinese, so the keyword path
// shares no token and contributes nothing. The ranking is therefore driven
// entirely by the vector index, proving the persistent vector path is used.
// 两条条目都是英文而 query 是中文,因此关键词路径
// 不共享任何 token、毫无贡献。排名因此完全由
// 向量索引驱动,证明走的是持久化向量路径。
item1 := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "she is a morning person",
Scope: ScopePersona, Kind: KindRelationship,
@@ -110,11 +110,11 @@ func TestRetriever_Retrieve_WithVectorIndex_PartialHits_正常(t *testing.T) {
Scope: ScopePersona, Kind: KindRelationship,
})
// The vector index only knows about item1; item2 is absent from it, so it
// carries no semantic score. With a Chinese query that the keyword path
// cannot match, only item1 is relevant: it must rank at the top with a
// positive score while item2 scores 0, proving the semantic contribution
// comes strictly from the vector index.
// 向量索引只知道 item1;item2 不在其中,因此它
// 没有语义得分。在关键词路径无法匹配的中文 query 下,
// 只有 item1 相关:它必须以正得分排在
// 首位,而 item2 得 0 分,证明语义贡献
// 严格来自向量索引。
idx := &mockVectorIndex{
searchFn: func(_ context.Context, _ vector.Vector, _ int) ([]vector.Hit, error) {
return []vector.Hit{{ID: item1.ID, Score: 0.9}}, nil
@@ -135,8 +135,8 @@ func TestRetriever_Retrieve_WithVectorIndex_PartialHits_正常(t *testing.T) {
if results[0].Score <= 0 {
t.Fatalf("top result score = %v, want > 0", results[0].Score)
}
// item2 has no vector hit and no keyword overlap, so it scores 0 (matched
// mode) and sorts below.
// item2 既无向量命中也无关键词重叠,因此得 0 分(已匹配
// 模式)并排在后面。
if results[1].Item.ID != item2.ID {
t.Fatalf("second result id = %q, want item2", results[1].Item.ID)
}
@@ -179,7 +179,7 @@ func TestRetriever_Retrieve_ProjectLink_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
// Separate project-index DB, so it shares no connection with the memory store.
// 独立的项目索引 DB,因此与记忆存储不共享连接。
projDB, err := db.Open(ctx, ":memory:", testLogger())
if err != nil {
t.Fatalf("open project db: %v", err)
@@ -233,8 +233,8 @@ func TestRetriever_Retrieve_ProjectLink_NoStore_正常(t *testing.T) {
Scope: ScopeProject, ProjectID: "project.alpha", Kind: KindWorldEvent,
})
// Without a project-index store wired, the hit still returns and Project is
// simply left unset (no error).
// 未接入项目索引存储时,命中仍会返回,Project
// 只是不设置(不报错)。
r := NewRetriever(ms)
results, err := r.Retrieve(ctx, "persona.a", "Alpha", 10)
if err != nil {
+3 -3
View File
@@ -9,7 +9,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// addItem is a convenience to persist an item and fail the test on error.
// addItem 是一个便捷函数:持久化一条条目,出错则让测试失败。
func addItem(t *testing.T, s *Store, item MemoryItem) MemoryItem {
t.Helper()
ctx := context.Background()
@@ -67,7 +67,7 @@ func TestRetriever_Retrieve_Recency_正常(t *testing.T) {
})
r := NewRetriever(s)
// A blank query matches nothing, so ordering falls back to recency.
// query 为空时无任何命中,因此排序回退到新近度。
results, err := r.Retrieve(ctx, "persona.a", "", 10)
if err != nil {
t.Fatalf("Retrieve: %v", err)
@@ -78,7 +78,7 @@ func TestRetriever_Retrieve_Recency_正常(t *testing.T) {
if got := results[0].Item.Content; got != "new memory" {
t.Fatalf("top result = %q, want the newer memory", got)
}
// The older memory must rank below.
// 较旧的记忆必须排在后面。
if got := results[1].Item.Content; got != "old memory" {
t.Fatalf("second result = %q, want the older memory", got)
}
+47 -47
View File
@@ -8,26 +8,26 @@ import (
"strings"
)
// Scope access control sentinels. These surface when a persona attempts a write
// it is not permitted to make under the design §3 rules.
// 作用域访问控制哨兵错误。当角色试图执行 design §3 规则
// 不允许的写入时返回。
var (
// ErrReadOnly is returned when a persona tries to write or delete a
// persona-scope memory item it does not own. Under design §3 other personas
// are read-only for 角色日常记忆.
// ErrReadOnly 在角色试图写入或删除
// 不属于它的角色作用域记忆条目时返回。按 design §3,其他角色
// 对角色日常记忆只读。
ErrReadOnly = errors.New("memory: read-only: persona does not own this item")
// ErrMissingProjectID is returned when a project-scoped query has a blank id.
// ErrMissingProjectID 在项目作用域查询的 id 为空时返回。
ErrMissingProjectID = errors.New("memory: project_id is required")
)
// canWrite reports whether actorID may write item under the design §3 scope rules:
// canWrite 报告 actorID 按 design §3 作用域规则是否可以写入 item:
//
// - ScopeUser: any persona may write (user habits are globally shared).
// - ScopePersona: only the owning persona may write (other personas read-only).
// - ScopeProject: any persona may write (project memory is 人人可写).
// - ScopeUser:任何角色都可写(用户习惯全局共享)。
// - ScopePersona:只有归属角色可写(其他角色只读)。
// - ScopeProject:任何角色都可写(项目记忆人人可写)。
//
// The caller must have already validated item.Scope; an invalid scope is not
// writable by anyone.
// 调用方必须已校验 item.Scope;非法作用域对任何人
// 都不可写。
func canWrite(actorID string, it MemoryItem) bool {
switch it.Scope {
case ScopeUser, ScopeProject:
@@ -39,11 +39,11 @@ func canWrite(actorID string, it MemoryItem) bool {
}
}
// AddScoped persists item under actorID's authority, enforcing the design §3
// write rules by persona_id + scope. A blank actorID returns ErrMissingPersona.
// When item.PersonaID is blank it is filled with actorID, so a persona always
// records its own provenance; a persona-scope item owned by a different persona
// returns ErrReadOnly. User- and project-scope items are writable by any persona.
// AddScoped 以 actorID 的权限持久化 item,按 persona_id + scope 施加
// design §3 写规则。actorID 为空返回 ErrMissingPersona。
// item.PersonaID 为空时用 actorID 填充它,因此角色总是
// 记录自身的来源;归属其他角色的角色作用域条目
// 返回 ErrReadOnly。用户与项目作用域的条目任何角色都可写。
func (s *Store) AddScoped(ctx context.Context, actorID string, item MemoryItem) (MemoryItem, error) {
if strings.TrimSpace(actorID) == "" {
return MemoryItem{}, ErrMissingPersona
@@ -60,19 +60,19 @@ func (s *Store) AddScoped(ctx context.Context, actorID string, item MemoryItem)
return s.Add(ctx, item)
}
// ListScope returns every item of scope visible to personaID, newest first,
// applying the design §3 read rules (rowid breaks created_at ties so items
// written within the same clock tick keep a stable newest-first order):
// ListScope 返回 personaID 可见的该作用域全部条目,最新的在前,
// 施加 design §3 读规则(rowid 打破 created_at 并列,使
// 同一时钟刻内写入的条目保持稳定的最新优先顺序):
//
// - ScopeUser: every user-scope item (globally shared across personas).
// - ScopePersona: every persona-scope item. Other personas are read-only but
// still readable, so a persona can read another persona's 角色日常记忆 here.
// - ScopeProject: every project-scope item (人人可读).
// - ScopeUser:全部用户作用域条目(跨角色全局共享)。
// - ScopePersona:全部角色作用域条目。其他角色只读但
// 仍可读,因此角色在这里可以读到另一个角色的角色日常记忆。
// - ScopeProject:全部项目作用域条目(人人可读)。
//
// personaID names the reading persona and is required so callers always declare
// the identity boundary even for a shared scope. The higher policy layer still
// decides whether a restricted context (group chat / stranger, design §5) may
// receive these; this method only enforces the base read rule.
// personaID 指明读取方角色,且必填,以便调用方即使在共享
// 作用域上也始终声明身份边界。是否允许受限上下文(群聊 / 陌生人,design §5)
// 接收这些内容仍由更高层策略决定;本方法只施加
// 基础读规则。
func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([]MemoryItem, error) {
if strings.TrimSpace(personaID) == "" {
return nil, ErrMissingPersona
@@ -90,13 +90,13 @@ func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([
return scanItems(rows, "scope item")
}
// ListProject returns every memory item linked to projectID, newest first, with
// rowid breaking created_at ties so same-tick writes keep a stable order. A
// project-scoped memory carries a ProjectID that links to the projectindex
// hierarchy (design §3); this is the deep-search entry point — callers combine
// it with the projectindex store to walk 项目索引 → 项目描述 → 章节内容. Project
// memory is readable by all personas, so no persona filter is applied. A blank
// projectID returns ErrMissingProjectID.
// ListProject 返回链接到 projectID 的全部记忆条目,最新的在前,
// rowid 打破 created_at 并列,使同一刻的写入保持稳定顺序。项目
// 作用域记忆携带一个 ProjectID,链到 projectindex
// 层级(design §3);这是深检索入口 —— 调用方把它与
// projectindex 存储结合,沿 项目索引 → 项目描述 → 章节内容 遍历。项目
// 记忆所有角色可读,因此不施加角色过滤。projectID 为空
// 返回 ErrMissingProjectID。
func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) {
if strings.TrimSpace(projectID) == "" {
return nil, ErrMissingProjectID
@@ -111,11 +111,11 @@ func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem
return scanItems(rows, "project item")
}
// DeleteScoped removes the item with the given id, but only if actorID may write
// it under the design §3 rules. This enforces the persona read-only boundary on
// deletion: a persona may delete its own persona-scope items and any user- or
// project-scope item, but not another persona's 角色日常记忆. A blank id returns
// ErrNotFound; writing a foreign persona-scope item returns ErrReadOnly.
// DeleteScoped 删除给定 id 的条目,但仅当按 design §3 规则 actorID
// 可以写它。这在删除上施加角色只读边界:
// 角色可以删自己的角色作用域条目,以及任何用户或
// 项目作用域条目,但不能删另一个角色的角色日常记忆。id 为空返回
// ErrNotFound;改动他人的角色作用域条目返回 ErrReadOnly。
func (s *Store) DeleteScoped(ctx context.Context, actorID, id string) error {
if strings.TrimSpace(actorID) == "" {
return ErrMissingPersona
@@ -130,19 +130,19 @@ func (s *Store) DeleteScoped(ctx context.Context, actorID, id string) error {
return s.Delete(ctx, id)
}
// ensureProjectIDColumn rolls the memory_items table forward when it was created
// before the M4 project index (design §3) existed: it adds the project_id column
// if it is missing, and ignores the duplicate-column result when present. It is
// SQLite-specific (the memory store is SQLite-backed) and idempotent, so NewStore
// may be called on a fresh schema or a pre-existing one.
// ensureProjectIDColumn 在 memory_items 表创建于 M4 项目索引
// (design §3)存在之前时把它前滚:缺失时补加 project_id 列,
// 已存在时忽略重复列错误。它是
// SQLite 特有的(记忆存储基于 SQLite)且幂等,因此 NewStore
// 可在全新 schema 或既有 schema 上调用。
func ensureProjectIDColumn(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`)
if err == nil {
return nil
}
// SQLite reports "duplicate column name: project_id" when the column already
// exists (e.g. created by the CREATE TABLE in NewStore, or by a previous run).
// 当该列已存在(例如由 NewStore 中的 CREATE TABLE 创建,
// 或由上一次运行创建)时,SQLite 报 "duplicate column name: project_id"。
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
+9 -9
View File
@@ -20,7 +20,7 @@ func TestStore_ListScope_User_全局共享(t *testing.T) {
}
}
// persona.a sees both personas' user-scope items (globally shared).
// persona.a 能看到两个角色的用户作用域条目(全局共享)。
items, err := s.ListScope(ctx, ScopeUser, "persona.a")
if err != nil {
t.Fatalf("ListScope user: %v", err)
@@ -41,7 +41,7 @@ func TestStore_ListScope_Persona_其他角色只读(t *testing.T) {
t.Fatalf("AddScoped persona.a: %v", err)
}
// persona.b may READ persona.a's persona-scope item (read-only rule).
// persona.b 可以读 persona.a 的角色作用域条目(只读规则)。
items, err := s.ListScope(ctx, ScopePersona, "persona.b")
if err != nil {
t.Fatalf("ListScope persona for b: %v", err)
@@ -49,7 +49,7 @@ func TestStore_ListScope_Persona_其他角色只读(t *testing.T) {
if len(items) != 1 || items[0].PersonaID != "persona.a" {
t.Fatalf("ListScope(persona) for persona.b = %+v, want persona.a's item", items)
}
// AddScoped auto-filled PersonaID from the acting actor.
// AddScoped 用实施动作者自动填充了 PersonaID。
if items[0].PersonaID != "persona.a" {
t.Errorf("owner persona = %q, want persona.a", items[0].PersonaID)
}
@@ -66,7 +66,7 @@ func TestStore_AddScoped_Persona_跨角色写_异常(t *testing.T) {
t.Fatalf("AddScoped persona.a: %v", err)
}
// persona.b cannot WRITE persona.a's persona-scope item.
// persona.b 不能写 persona.a 的角色作用域条目。
_, err := s.AddScoped(ctx, "persona.b", MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "b想改a的日常",
@@ -81,7 +81,7 @@ func TestStore_AddScoped_UserProject_人人写(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
// Any persona may write a user- or project-scope item.
// 任何角色都可以写用户或项目作用域的条目。
for _, sc := range []Scope{ScopeUser, ScopeProject} {
item, err := s.AddScoped(ctx, "persona.b", MemoryItem{
Scope: sc, Kind: KindWorldEvent, Content: "共享-" + string(sc),
@@ -89,7 +89,7 @@ func TestStore_AddScoped_UserProject_人人写(t *testing.T) {
if err != nil {
t.Fatalf("AddScoped %s: %v", sc, err)
}
// PersonaID was auto-filled with the acting actor.
// PersonaID 已用实施动作者自动填充。
if item.PersonaID != "persona.b" {
t.Errorf("AddScoped %s owner = %q, want persona.b", sc, item.PersonaID)
}
@@ -145,12 +145,12 @@ func TestStore_DeleteScoped_跨角色_异常(t *testing.T) {
t.Fatalf("AddScoped persona.a: %v", err)
}
// persona.a deletes its own item.
// persona.a 删除自己的条目。
if err := s.DeleteScoped(ctx, "persona.a", stored.ID); err != nil {
t.Fatalf("DeleteScoped owner: %v", err)
}
// Re-add, then persona.b cannot delete persona.a's item.
// 重新添加,然后 persona.b 不能删除 persona.a 的条目。
stored2, err := s.AddScoped(ctx, "persona.a", MemoryItem{
Scope: ScopePersona, Kind: KindRelationship, Content: "a的日常2",
})
@@ -160,7 +160,7 @@ func TestStore_DeleteScoped_跨角色_异常(t *testing.T) {
if err := s.DeleteScoped(ctx, "persona.b", stored2.ID); !errors.Is(err, ErrReadOnly) {
t.Fatalf("DeleteScoped cross-persona err = %v, want ErrReadOnly", err)
}
// The foreign item survives.
// 他人的条目依然存在。
if _, err := s.Get(ctx, stored2.ID); err != nil {
t.Fatalf("foreign item removed: %v", err)
}
+108 -108
View File
@@ -1,30 +1,30 @@
// Package memory implements the Cyrene v2 memory subsystem described in
// DevDocs/design/03-记忆.md.
// memory 包实现 Cyrene v2 记忆子系统,规格见
// DevDocs/design/03-记忆.md。
//
// It exposes three layers:
// 它对外暴露三层:
//
// - Store: a SQLite-backed long-term memory store. It persists memory items
// with persona/role isolation (design §0 layer ②, §4), applies the
// three-scope access rules (design §3) via AddScoped/ListScope/ListProject/
// DeleteScoped, and offers Add/Get/ListByPersona/Delete plus a manual
// "记住" path (design §1). Each item may carry a project_id linking the
// project-index hierarchy.
// - WorkingMemory: a per-session sliding window of messages plus a summary
// (design §0 layer ①).
// - Retriever: relevance (keyword + optional semantic) × decay retrieval with
// source provenance and context injection (design §2). It accepts an optional
// vector.Index for the persistent semantic path and a projectindex.Store so
// project-scoped hits surface their linked project entry.
// - Store:基于 SQLite 的长期记忆存储。它按角色隔离持久化记忆条目
// (design §0 layer ②、§4),经 AddScoped/ListScope/ListProject/
// DeleteScoped 施加三作用域访问规则(design §3),并提供
// Add/Get/ListByPersona/Delete,外加一条手动的
// 「记住」路径(design §1)。每条条目可携带 project_id,链到
// 项目索引层级。
// - WorkingMemory:每会话的消息滑动窗口加一份摘要
// (design §0 layer ①)。
// - Retriever:相关性(关键词 + 可选语义)× 衰减检索,带
// 溯源与上下文注入(design §2)。它可选接收
// vector.Index 作为持久化语义路径,以及 projectindex.Store,使
// 项目作用域的命中带出其链接的项目条目。
//
// Extraction (design §1) is exposed through the Extractor interface with two
// implementations: ManualExtractor (rule-based, records an explicit "记住 X"
// directive) and LLMExtractor (drives a model.Adapter to denoise and deduplicate
// a turn, running synchronously via Extract or asynchronously via ExtractAsync).
// 提取(design §1)经 Extractor 接口暴露,有两个
// 实现:ManualExtractor(规则式,记录一条显式的「记住 X」
// 指令)与 LLMExtractor(驱动 model.Adapter 对
// 一轮对话去噪去重,经 Extract 同步运行或经 ExtractAsync 异步运行)。
//
// Milestone M4 is implemented here: the schema carries decay_weight and
// project_id, the retriever plugs an optional embedding.Embedder / vector.Index
// and a project-index store, and the store enforces the user/persona/project
// scope rules. No external dependency is needed beyond the existing packages.
// M4 里程碑在此实现:schema 携带 decay_weight 与
// project_id,检索器接入可选的 embedding.Embedder / vector.Index
// 与项目索引存储,存储层施加 user/persona/project
// 作用域规则。除既有包外不需要任何外部依赖。
package memory
import (
@@ -43,20 +43,20 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// Scope classifies who a long-term memory belongs to. It is orthogonal to Kind
// and both are stored on every item (design §3 note).
// Scope 分类一条长期记忆归属于谁。它与 Kind 正交,
// 两者都存于每条条目上(design §3 注)。
type Scope string
const (
// ScopeUser is a memory about a user's habits, shared across personas.
// ScopeUser 是关于用户习惯的记忆,跨角色共享。
ScopeUser Scope = "user"
// ScopePersona is a persona's everyday memory; other personas are read-only.
// ScopePersona 是角色的日常记忆;其他角色只读。
ScopePersona Scope = "persona"
// ScopeProject is a project-scoped memory, readable and writable by all.
// ScopeProject 是项目作用域记忆,人人可读可写。
ScopeProject Scope = "project"
)
// valid reports whether s is one of the defined scopes.
// valid 报告 s 是否为已定义的作用域之一。
func (s Scope) valid() bool {
switch s {
case ScopeUser, ScopePersona, ScopeProject:
@@ -66,19 +66,19 @@ func (s Scope) valid() bool {
}
}
// Kind classifies the content category of a long-term memory item.
// Kind 分类一条长期记忆条目的内容类别。
type Kind string
const (
// KindRelationship is a memory about "you and me": preferences, habits,
// agreements (design §0 "关系记忆").
// KindRelationship 是关于「你和我」的记忆:偏好、习惯、
// 约定(design §0「关系记忆」)。
KindRelationship Kind = "relationship"
// KindWorldEvent is a memory about objective facts and events in the world
// (design §0 "世界/事件记忆").
// KindWorldEvent 是关于客观事实与世界事件的记忆
// (design §0「世界/事件记忆」)。
KindWorldEvent Kind = "world_event"
)
// valid reports whether k is one of the defined kinds.
// valid 报告 k 是否为已定义的 kind 之一。
func (k Kind) valid() bool {
switch k {
case KindRelationship, KindWorldEvent:
@@ -88,9 +88,9 @@ func (k Kind) valid() bool {
}
}
// MemoryItem is a single long-term memory record. The zero value is not a valid
// item: PersonaID and Content are required, Scope and Kind are normalized to
// their defaults by Add.
// MemoryItem 是一条长期记忆记录。零值不是合法
// 条目:PersonaID 与 Content 必填,Scope 与 Kind 由 Add 规范化到
// 其默认值。
type MemoryItem struct {
ID string
PersonaID string
@@ -107,59 +107,59 @@ type MemoryItem struct {
DecayWeight float64
}
// Sentinels distinguishing memory package failures. Each is wrapped with %w at
// the call site so callers can use errors.Is.
// 区分 memory 包各类失败的哨兵错误。每个都在
// 调用点用 %w 包裹,调用方可用 errors.Is 判断。
var (
// ErrMissingPersona is returned when an item has no PersonaID.
// ErrMissingPersona 在条目没有 PersonaID 时返回。
ErrMissingPersona = errors.New("memory: persona_id is required")
// ErrMissingContent is returned when an item has no content.
// ErrMissingContent 在条目没有内容时返回。
ErrMissingContent = errors.New("memory: content is required")
// ErrInvalidScope is returned when an item's Scope is not a defined scope.
// ErrInvalidScope 在条目的 Scope 不是已定义作用域时返回。
ErrInvalidScope = errors.New("memory: invalid scope")
// ErrInvalidKind is returned when an item's Kind is not a defined kind.
// ErrInvalidKind 在条目的 Kind 不是已定义 kind 时返回。
ErrInvalidKind = errors.New("memory: invalid kind")
// ErrNotFound is returned when an item id does not exist.
// ErrNotFound 在条目 id 不存在时返回。
ErrNotFound = errors.New("memory: item not found")
// ErrNilDB is returned when NewStore receives a nil *sql.DB.
// ErrNilDB 在 NewStore 收到 nil *sql.DB 时返回。
ErrNilDB = errors.New("memory: nil db")
)
// Schema defaults and time-window constants.
// schema 默认值与时间窗口常量。
const (
defaultConfidence = 1.0
defaultDecayWeight = 1.0
)
// Store is a long-term memory store backed by a *sql.DB.
// Store 是基于 *sql.DB 的长期记忆存储。
//
// The store owns its schema: NewStore creates the memory_items table and its
// indexes idempotently. Concurrency is delegated to *sql.DB, which is safe for
// concurrent use by contract, so Store holds no further lock. Every query is
// parameterized (?) and lists columns explicitly (no SELECT *), and reads take
// a context.Cancel.
// 存储自己拥有 schema:NewStore 幂等地创建 memory_items 表及其
// 索引。并发交由 *sql.DB,按约定它可安全
// 并发使用,因此 Store 不再持锁。每个查询都
// 参数化(?)并显式列出列(无 SELECT *),读取带
// context.Cancel。
type Store struct {
db *sql.DB
log *slog.Logger
}
// rowScanner is satisfied by both *sql.Row and *sql.Rows, so a single scanItem
// helper serves Get and ListByPersona.
// rowScanner 同时被 *sql.Row 与 *sql.Rows 满足,因此单个 scanItem
// 辅助函数即可服务 Get 与 ListByPersona。
type rowScanner interface {
Scan(dest ...any) error
}
// NewStore builds a Store on db, ensuring the memory_items schema exists.
// NewStore 在 db 上构建 Store,确保 memory_items schema 存在。
//
// db must be a live, migrated connection (the migration framework itself is
// owned by internal/db). A nil db returns ErrNilDB. log may be nil, in which
// case a discard logger is used. ctx controls schema creation and must be
// honored by the caller. Any legacy variable-width timestamps are normalized in
// place (see normalizeMemoryTimestamps), so text ordering matches time ordering.
// db 必须是存活且已迁移的连接(迁移框架本身由
// internal/db 拥有)。nil db 返回 ErrNilDB。log 可为 nil,此时
// 使用丢弃型 logger。ctx 控制 schema 创建,调用方必须
// 遵守。任何旧的变宽时间戳都就地
// 规范化(见 normalizeMemoryTimestamps),使文本顺序与时间顺序一致。
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil {
return nil, ErrNilDB
@@ -183,9 +183,9 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
return &Store{db: db, log: log}, nil
}
// Add persists item, assigning an id and missing timestamps, and normalizing
// Scope/Kind/Confidence/DecayWeight to their defaults. It returns the stored
// item with the id and timestamps filled in.
// Add 持久化 item,分配 id 与缺失的时间戳,并把
// Scope/Kind/Confidence/DecayWeight 规范化到默认值。它返回已落库的
// 条目,id 与时间戳均已填充。
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
if err := item.normalize(); err != nil {
return MemoryItem{}, err
@@ -212,8 +212,8 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
return item, nil
}
// Get returns the item with the given id. It returns ErrNotFound when the id is
// empty or does not exist.
// Get 返回给定 id 的条目。id 为空或
// 不存在时返回 ErrNotFound。
func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
if strings.TrimSpace(id) == "" {
return MemoryItem{}, ErrNotFound
@@ -231,13 +231,13 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
return item, nil
}
// ListByPersona returns every item for personaID, newest first. rowid breaks
// created_at ties so items written within the same clock tick keep a stable
// newest-first order. It is the persona-isolation boundary: items belonging to
// other personas are never returned, so personas never share their own 角色日常
// namespace (design §4). It is only the *own* half of the retrieval candidate
// set — the retriever unions it with the shared user/project scopes before
// ranking (design §3/§4, see Retriever.visibleCandidates).
// ListByPersona 返回 personaID 的全部条目,最新的在前。rowid 打破
// created_at 并列,使同一时钟刻内写入的条目保持稳定的
// 最新优先顺序。它是角色隔离边界:属于
// 其他角色的条目绝不返回,因此角色之间从不共享各自的角色日常
// 命名空间(design §4)。它只是检索候选集的*自有*
// 那一半 —— 检索器在排名前把它与共享的用户/项目作用域
// 取并集(design §3/§4,见 Retriever.visibleCandidates)。
func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) {
if strings.TrimSpace(personaID) == "" {
return nil, ErrMissingPersona
@@ -252,12 +252,12 @@ func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryIt
return scanItems(rows, "persona item")
}
// List returns every memory item, newest first, across all personas. rowid
// breaks created_at ties so items written within the same clock tick keep a
// stable newest-first order. It is the aggregate/admin view that enumerates all
// persisted memory regardless of the persona-isolation boundary; callers that
// must respect isolation should use ListByPersona or ListScope instead. The
// returned slice is non-nil.
// List 返回跨所有角色的每条记忆条目,最新的在前。rowid
// 打破 created_at 并列,使同一时钟刻内写入的条目保持
// 稳定的最新优先顺序。它是聚合/管理视图,枚举全部
// 已持久化记忆,不受角色隔离边界限制;必须
// 遵守隔离的调用方应改用 ListByPersona 或 ListScope。返回的
// 切片非 nil。
func (s *Store) List(ctx context.Context) ([]MemoryItem, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
@@ -269,9 +269,9 @@ func (s *Store) List(ctx context.Context) ([]MemoryItem, error) {
return scanItems(rows, "item")
}
// scanItems reads every row into a MemoryItem slice, closing rows and wrapping
// scan/iterate failures with the operation name. A non-nil, possibly empty slice
// is always returned on success.
// scanItems 把每一行读入 MemoryItem 切片,关闭 rows 并用
// 操作名包裹 scan/遍历失败。成功时总是返回
// 非 nil、可能为空的切片。
func scanItems(rows *sql.Rows, op string) ([]MemoryItem, error) {
defer func() { _ = rows.Close() }()
@@ -289,10 +289,10 @@ func scanItems(rows *sql.Rows, op string) ([]MemoryItem, error) {
return items, nil
}
// Delete removes the item with the given id and returns ErrNotFound when the id
// is empty or no row was removed. Callers should only pass ids they obtained
// through a persona-scoped read (ListByPersona/Get) so deletion stays within
// the caller's persona boundary.
// Delete 删除给定 id 的条目,id 为空或
// 没有删除任何行时返回 ErrNotFound。调用方只应传入经
// 角色作用域读取(ListByPersona/Get)得到的 id,使删除始终留在
// 调用方的角色边界内。
func (s *Store) Delete(ctx context.Context, id string) error {
if strings.TrimSpace(id) == "" {
return ErrNotFound
@@ -311,9 +311,9 @@ func (s *Store) Delete(ctx context.Context, id string) error {
return nil
}
// RememberManual writes a single long-term memory item for content text — the
// "记住 X" path from design §1. The item is scoped to the persona by default and
// recorded as a relationship memory at full confidence. Returns the stored item.
// RememberManual 为 content 文本写入一条长期记忆条目 —— design §1 中的
// 「记住 X」路径。条目默认取角色作用域,
// 并以满置信度记为关系记忆。返回已落库的条目。
func (s *Store) RememberManual(ctx context.Context, personaID, roleID, text string) (MemoryItem, error) {
item := MemoryItem{
PersonaID: personaID,
@@ -328,9 +328,9 @@ func (s *Store) RememberManual(ctx context.Context, personaID, roleID, text stri
return s.Add(ctx, item)
}
// normalize fills default Scope/Kind/Confidence/DecayWeight then validates.
// It is called by Add so callers may omit values while still catching
// explicitly invalid ones.
// normalize 填充默认的 Scope/Kind/Confidence/DecayWeight,然后校验。
// 它由 Add 调用,因此调用方可以省略取值,同时仍能
// 捕获显式非法值。
func (it *MemoryItem) normalize() error {
if it.Scope == "" {
it.Scope = ScopePersona
@@ -347,8 +347,8 @@ func (it *MemoryItem) normalize() error {
return it.validate()
}
// validate checks required and enumerated fields. It is pure: it does not apply
// defaults, so it can also be used to reject a caller-provided value.
// validate 检查必填与枚举字段。它是纯函数:不施加
// 默认值,因此也可用于拒绝调用方提供的值。
func (it *MemoryItem) validate() error {
if strings.TrimSpace(it.PersonaID) == "" {
return ErrMissingPersona
@@ -365,8 +365,8 @@ func (it *MemoryItem) validate() error {
return nil
}
// applyTimestamps assigns an id and missing CreatedAt/UpdatedAt values. It
// returns an error only when a fresh id cannot be generated.
// applyTimestamps 分配 id 与缺失的 CreatedAt/UpdatedAt 值。
// 仅在无法生成新 id 时返回错误。
func applyTimestamps(item MemoryItem) (MemoryItem, error) {
now := time.Now().UTC()
if item.ID == "" {
@@ -385,9 +385,9 @@ func applyTimestamps(item MemoryItem) (MemoryItem, error) {
return item, nil
}
// scanItem reads one memory_items row. tags is stored as a JSON array string;
// timestamps are stored as fixed-width UTC strings (timeLayout) so text
// comparison orders them chronologically.
// scanItem 读取一行 memory_items。tags 以 JSON 数组字符串存储;
// 时间戳以定宽 UTC 字符串(timeLayout)存储,因此文本
// 比较即可按时间排序。
func scanItem(sc rowScanner) (MemoryItem, error) {
var (
it MemoryItem
@@ -419,9 +419,9 @@ func scanItem(sc rowScanner) (MemoryItem, error) {
return it, nil
}
// newID returns a 128-bit cryptographically random id, hex-encoded. It avoids a
// UUID dependency so the package stays self-contained. rand.Read fails only if
// the platform entropy source is broken, so the error is propagated.
// newID 返回一个 128 位密码学随机 id,十六进制编码。它避开
// UUID 依赖,使本包保持自包含。rand.Read 只在
// 平台熵源损坏时失败,因此错误向上传播。
func newID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
@@ -430,7 +430,7 @@ func newID() (string, error) {
return hex.EncodeToString(b), nil
}
// discardLogger returns log if non-nil, otherwise a logger that writes nowhere.
// discardLogger 在 log 非 nil 时返回 log,否则返回一个不写任何地方的 logger。
func discardLogger(log *slog.Logger) *slog.Logger {
if log != nil {
return log
@@ -438,11 +438,11 @@ func discardLogger(log *slog.Logger) *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// Schema DDL. The migration framework in internal/db is the authoritative
// mechanism for domain tables, but it is intentionally not used here: the db
// package's migration tests assert an exact migration count, and the memory
// store must be self-contained and usable from any *sql.DB. The table and
// indexes are therefore created idempotently by NewStore.
// schema DDL。internal/db 中的迁移框架是领域表的权威
// 机制,但这里刻意不用它:db 包的迁移测试断言精确的
// 迁移数量,而记忆存储必须自包含、
// 可在任意 *sql.DB 上使用。表与索引因此
// 由 NewStore 幂等创建。
const createMemoryItemsTable = `
CREATE TABLE IF NOT EXISTS memory_items (
id TEXT PRIMARY KEY,
+15 -15
View File
@@ -23,7 +23,7 @@ func TestStore_AddGet_正常(t *testing.T) {
s := newTestStore(t)
item := baseItem()
// Add returns the stored item with id and timestamps filled in.
// Add 返回已落库条目,id 与时间戳均已填充。
stored, err := s.Add(ctx, item)
if err != nil {
t.Fatalf("Add: %v", err)
@@ -70,7 +70,7 @@ func TestStore_Add_Defaults_正常(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
// Empty Scope/Kind/Confidence/DecayWeight normalize to their defaults.
// 空的 Scope/Kind/Confidence/DecayWeight 规范化到其默认值。
stored, err := s.Add(ctx, MemoryItem{
PersonaID: "persona.a",
Content: "我们约定每周日晚上看电影",
@@ -177,7 +177,7 @@ func TestStore_List_正常(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
// An empty store lists nothing but stays non-nil.
// 空存储列不出任何东西,但仍是非 nil。
if got, err := s.List(ctx); err != nil || got == nil || len(got) != 0 {
t.Fatalf("List() on empty store = %#v, err=%v; want empty non-nil", got, err)
}
@@ -199,7 +199,7 @@ func TestStore_List_正常(t *testing.T) {
if len(got) != 3 {
t.Fatalf("List = %d items, want 3", len(got))
}
// Newest first (created_at DESC).
// 最新的在前(created_at DESC)。
if got[0].Content != "项目记忆" {
t.Errorf("got[0].Content = %q, want the latest added (项目记忆)", got[0].Content)
}
@@ -211,21 +211,21 @@ func TestStore_List_正常(t *testing.T) {
}
}
// TestStore_List_变宽时间戳_排序 is the regression for the variable-width
// timestamp bug: time.RFC3339Nano drops trailing fractional zeros, so the older
// item was once stored as ":05.83" and the newer as ":05.8309789", and the text
// comparison SQLite applies to these columns ranked the older one as newer.
// TestStore_List_变宽时间戳_排序 是变宽时间戳缺陷的回归:
// time.RFC3339Nano 会丢弃末尾小数零,因此较旧的条目
// 曾被存成 ":05.83"、较新的存成 ":05.8309789",而 SQLite 对这些列
// 施加的文本比较把较旧的判成更新的。
func TestStore_List_变宽时间戳_排序(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
sec := time.Date(2026, 9, 7, 3, 20, 5, 0, time.UTC)
older := sec.Add(830 * time.Millisecond) // ":05.83" under RFC3339Nano
newer := older.Add(978900 * time.Nanosecond) // ":05.8309789" under RFC3339Nano
older := sec.Add(830 * time.Millisecond) // RFC3339Nano 下的 ":05.83"
newer := older.Add(978900 * time.Nanosecond) // RFC3339Nano 下的 ":05.8309789"
// The newer item is inserted first, so a rowid-only tiebreak would surface
// the older one: only correctly ordered created_at text yields 较晚 first.
// 较新的条目先插入,因此只按 rowid 打破并列会把较旧的
// 抬到前面:只有正确排序的 created_at 文本才会让较晚的在前。
for _, in := range []struct {
content string
at time.Time
@@ -260,9 +260,9 @@ func TestStore_List_变宽时间戳_排序(t *testing.T) {
}
}
// TestStore_List_同一时间戳_插入序 covers the tie case: the host clock is coarse
// enough that three consecutive Adds can share one timestamp, so ordering must
// fall back to rowid rather than an arbitrary row order.
// TestStore_List_同一时间戳_插入序 覆盖并列情形:宿主时钟
// 精度足够粗,三次连续的 Add 可能共享同一时间戳,因此排序必须
// 回退到 rowid,而不是任意行序。
func TestStore_List_同一时间戳_插入序(t *testing.T) {
t.Parallel()
ctx := context.Background()
+39 -39
View File
@@ -8,33 +8,33 @@ import (
"time"
)
// timeLayout is the fixed-width timestamp layout used for every stored
// created_at/updated_at value. Unlike time.RFC3339Nano it never drops trailing
// fractional zeros, so every value is exactly storedTimeLen bytes wide. SQLite
// compares these TEXT columns as plain strings, and equal width is what makes
// that comparison agree with chronological order. UTC values render the zone as
// a single "Z".
// timeLayout 是每个已存 created_at/updated_at 值使用的定宽
// 时间戳布局。与 time.RFC3339Nano 不同,它从不丢弃末尾
// 小数零,因此每个值恰好 storedTimeLen 字节宽。SQLite
// 把这些 TEXT 列按普通字符串比较,而等宽正是让
// 该比较与时间顺序一致的原因。UTC 值把时区渲染为
// 单个 "Z"。
const timeLayout = "2006-01-02T15:04:05.000000000Z07:00"
// storedTimeLen is the byte length of a UTC value formatted with timeLayout:
// "2006-01-02T15:04:05.000000000Z" — the Z07:00 zone collapses to "Z" in UTC.
// storedTimeLen 是用 timeLayout 格式化的 UTC 值的字节长度:
// "2006-01-02T15:04:05.000000000Z" —— Z07:00 时区在 UTC 下坍缩为 "Z"。
const storedTimeLen = 30
// memoryItemsTable is the table whose timestamps are normalized when the store
// opens. It must match the DDL in store.go.
// memoryItemsTable 是存储打开时需要规范化时间戳的表。
// 它必须与 store.go 中的 DDL 一致。
const memoryItemsTable = "memory_items"
// formatTime encodes t in UTC with the fixed-width timeLayout. Always writing
// UTC keeps ordering and round-trips stable across processes, and the fixed
// width is what lets ORDER BY created_at order these TEXT columns by time.
// formatTime 用定宽 timeLayout 把 t 编码为 UTC。始终写
// UTC 使排序与往返在各进程间保持稳定,而定宽
// 正是 ORDER BY created_at 能按时间排序这些 TEXT 列的原因。
func formatTime(t time.Time) string {
return t.UTC().Format(timeLayout)
}
// parseTime decodes a stored timestamp. time.RFC3339Nano accepts both the
// fixed-width values this package writes and the legacy variable-width ones, so
// the same parser serves reads and the open-time normalization. Blank input
// yields a zero time.
// parseTime 解码一个已存时间戳。time.RFC3339Nano 同时接受
// 本包写入的定宽值与旧的变宽值,因此同一个解析器既服务
// 读取、也服务打开时的规范化。空输入
// 得到零值时间。
func parseTime(s string) (time.Time, error) {
if strings.TrimSpace(s) == "" {
return time.Time{}, nil
@@ -46,15 +46,15 @@ func parseTime(s string) (time.Time, error) {
return t, nil
}
// normalizeMemoryTimestamps rewrites every memory_items timestamp that is not
// storedTimeLen bytes wide into the fixed-width timeLayout. Rows written before
// the layout existed carry variable-width RFC3339Nano text whose string order
// is not its time order; this brings them in line.
// normalizeMemoryTimestamps 把每个非 storedTimeLen 字节宽的 memory_items
// 时间戳重写为定宽 timeLayout。布局存在之前写入的行
// 携带变宽 RFC3339Nano 文本,其字符串顺序
// 并非时间顺序;这把它们拉齐。
//
// It is idempotent: only rows whose column length differs from storedTimeLen are
// selected, so running it again on a normalized table is a no-op. A row whose
// value does not parse is left exactly as it is — one bad legacy value must not
// keep the store from opening.
// 它是幂等的:只选取列长度与 storedTimeLen 不同的行,
// 因此在已规范化的表上再跑一次是空操作。值解析不了的行
// 原样保留 —— 一个坏的遗留值不该
// 妨碍存储打开。
func normalizeMemoryTimestamps(ctx context.Context, db *sql.DB) error {
if err := normalizeTimeColumn(ctx, db, memoryItemsTable, "created_at"); err != nil {
return err
@@ -62,11 +62,11 @@ func normalizeMemoryTimestamps(ctx context.Context, db *sql.DB) error {
return normalizeTimeColumn(ctx, db, memoryItemsTable, "updated_at")
}
// normalizeTimeColumn rewrites every table.col value that is not storedTimeLen
// bytes wide. table and col are compile-time constants, never user input, so
// splicing them into the statement is safe; the length comparison and every
// value written are parameterized. All rewrites are collected before any UPDATE
// runs, so no write happens while the SELECT cursor is still open.
// normalizeTimeColumn 重写 table.col 中每个非 storedTimeLen
// 字节宽的值。table 与 col 是编译期常量,绝非用户输入,因此
// 把它们拼进语句是安全的;长度比较与每个
// 写入值都参数化。所有重写都在任何 UPDATE
// 执行前收集完毕,因此 SELECT 游标仍打开时不会发生写入。
func normalizeTimeColumn(ctx context.Context, db *sql.DB, table, col string) error {
fixes, err := collectLegacyTimestamps(ctx, db, table, col)
if err != nil {
@@ -81,8 +81,8 @@ func normalizeTimeColumn(ctx context.Context, db *sql.DB, table, col string) err
return nil
}
// timeFix is one pending timestamp rewrite: which table and column, which row
// (by primary key id), and the fixed-width value to write.
// timeFix 是一条待执行的时间戳重写:哪张表哪一列、哪一行
// (按主键 id),以及要写入的定宽值。
type timeFix struct {
table string
col string
@@ -90,10 +90,10 @@ type timeFix struct {
value string
}
// collectLegacyTimestamps reads the rows of table.col whose value is not
// storedTimeLen bytes wide and returns the parseable ones as pending rewrites.
// Unparseable values are skipped, so normalization can never fail a row: the
// only errors are genuine read failures.
// collectLegacyTimestamps 读取 table.col 中值非 storedTimeLen
// 字节宽的行,把可解析的作为待重写项返回。
// 不可解析的值跳过,因此规范化绝不会让某行失败:
// 唯一的错误是真正的读取失败。
func collectLegacyTimestamps(ctx context.Context, db *sql.DB, table, col string) ([]timeFix, error) {
rows, err := db.QueryContext(ctx,
"SELECT id, "+col+" FROM "+table+" WHERE length("+col+") != ?", storedTimeLen)
@@ -120,9 +120,9 @@ func collectLegacyTimestamps(ctx context.Context, db *sql.DB, table, col string)
return fixes, nil
}
// reformatTime re-encodes raw with timeLayout. The bool is false when raw is
// already storedTimeLen wide or when it does not parse, in which case the caller
// leaves the stored value untouched.
// reformatTime 用 timeLayout 重新编码 raw。raw 已经
// storedTimeLen 宽、或无法解析时 bool 为 false,此时调用方
// 保持存储值不动。
func reformatTime(raw string) (string, bool) {
if len(raw) == storedTimeLen {
return "", false
+14 -14
View File
@@ -9,8 +9,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// newTestDB opens an isolated in-memory SQLite DB for tests that must inspect
// the raw stored timestamp text.
// newTestDB 为需要查看原始落库时间戳文本的测试打开一个隔离的
// 内存 SQLite 库。
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
@@ -22,8 +22,8 @@ func newTestDB(t *testing.T) *sql.DB {
return d
}
// insertLegacyItemSQL writes a memory_items row with caller-supplied raw
// timestamps, standing in for a row persisted before the fixed-width layout.
// insertLegacyItemSQL 用调用方给定的原始时间戳写入一行 memory_items,
// 代表定宽布局之前持久化的行。
const insertLegacyItemSQL = `INSERT INTO memory_items
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence,
source_conversation, created_at, updated_at, decay_weight)
@@ -58,16 +58,16 @@ func TestFormatTime_定宽_正常(t *testing.T) {
func TestFormatTime_非UTC_正常(t *testing.T) {
t.Parallel()
zone := time.FixedZone("UTC+8", 8*60*60)
// 11:20:05.830+08:00 is 03:20:05.830Z, so the stored text is UTC.
// 11:20:05.830+08:00 即 03:20:05.830Z,因此落库文本是 UTC。
at := time.Date(2026, 9, 7, 11, 20, 5, 830000000, zone)
if got, want := formatTime(at), "2026-09-07T03:20:05.830000000Z"; got != want {
t.Errorf("formatTime = %q, want %q", got, want)
}
}
// TestNewStore_归一化历史时间戳_正常 writes a legacy variable-width row directly,
// re-opens the store to trigger normalization, and checks both the repaired text
// and its idempotence on a further open.
// TestNewStore_归一化历史时间戳_正常 直接写入一行旧的变宽数据,
// 重新打开存储触发规范化,并检查修复后的文本
// 以及再次打开时的幂等性。
func TestNewStore_归一化历史时间戳_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -77,8 +77,8 @@ func TestNewStore_归一化历史时间戳_正常(t *testing.T) {
t.Fatalf("NewStore: %v", err)
}
// RFC3339Nano drops the trailing fractional zeros, so this legacy value is
// only 23 bytes wide and loses the text-ordering guarantee.
// RFC3339Nano 丢弃末尾小数零,因此这个遗留值
// 只有 23 字节宽,失去了文本排序保证。
const legacy = "2026-09-07T03:20:05.83Z"
if _, err := d.ExecContext(ctx, insertLegacyItemSQL, "legacy", legacy, legacy); err != nil {
t.Fatalf("insert legacy row: %v", err)
@@ -106,8 +106,8 @@ func TestNewStore_归一化历史时间戳_正常(t *testing.T) {
t.Errorf("timestamps = %v/%v, want %v", got.CreatedAt, got.UpdatedAt, wantTime)
}
// A further open must be a no-op: the length condition selects nothing once
// the row is fixed width.
// 再次打开必须是空操作:行变为定宽后,
// 长度条件不会选中任何东西。
if _, err := NewStore(ctx, d, testLogger()); err != nil {
t.Fatalf("NewStore (second pass): %v", err)
}
@@ -121,8 +121,8 @@ func TestNewStore_归一化历史时间戳_正常(t *testing.T) {
}
}
// TestNewStore_无法解析的时间戳_跳过 pins the "leave it alone" rule: one bad legacy
// value must not keep the store from opening, and must not be rewritten.
// TestNewStore_无法解析的时间戳_跳过 钉住「原样保留」规则:一个坏的
// 遗留值既不该妨碍存储打开,也不该被重写。
func TestNewStore_无法解析的时间戳_跳过(t *testing.T) {
t.Parallel()
ctx := context.Background()
+55 -55
View File
@@ -1,22 +1,22 @@
// Package vector implements dense-vector similarity search for Cyrene's
// long-term memory layer (DevDocs/design/03-记忆.md §3).
// vector 包为 Cyrene 的长期记忆层(DevDocs/design/03-记忆.md §3)
// 实现稠密向量相似度检索。
//
// It exposes an Index abstraction over two storage backends:
// 它在两种存储后端之上暴露 Index 抽象:
//
// - SQLiteIndex (the default): vectors are stored as BLOBs in a SQLite
// vec_items table and similarity is computed in Go as cosine similarity via
// a linear scan over the persisted vectors. This is the zero-extra-
// dependency path used when DB_PG_URL is empty.
// - PGVectorIndex (optional enhancement): uses PostgreSQL + pgvector, storing
// vectors in a vector column and answering similarity with the cosine-
// distance operator <=> in SQL. It is selected only when DB_PG_URL is set;
// when it is, semantic search is genuinely backed by pgvector and does not
// silently fall back to keyword matching.
// - SQLiteIndex(默认):向量以 BLOB 存在 SQLite 的
// vec_items 表中,相似度在 Go 侧通过对已持久化向量
// 线性扫描计算余弦相似度。这是 DB_PG_URL 为空时使用的
// 零额外依赖路径。
// - PGVectorIndex(可选增强):使用 PostgreSQL + pgvector,把
// 向量存入 vector 列,并用 SQL 中的余弦
// 距离运算符 <=> 作答相似度。仅当设置了 DB_PG_URL 时才选中;
// 此时语义检索真正由 pgvector 支撑,不会
// 静默退化为关键词匹配。
//
// Both backends are linear-scan for milestone M4 (small datasets, no ANN
// training). The PostgreSQL path may later be upgraded to an HNSW index as data
// grows; the Search SQL is unchanged. Vectors are produced by an
// embedding.Embedder wired in via New.
// 两个后端在 M4 里程碑都是线性扫描(数据集小,不做 ANN
// 训练)。随着数据增长,PostgreSQL 路径日后可升级为 HNSW 索引;
// Search SQL 不变。向量由经 New 接入的
// embedding.Embedder 产出。
package vector
import (
@@ -33,22 +33,22 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// Vector is a dense embedding: one float32 per dimension.
// Vector 是一个稠密向量:每维一个 float32。
type Vector []float32
// Hit is a single similarity result: a stored id and its similarity score.
// Higher Score means more similar, so results sort in descending Score order.
// Hit 是一条相似度结果:一个已存 id 及其相似度得分。
// Score 越高越相似,因此结果按 Score 降序排列。
type Hit struct {
ID string
Score float64
}
// Index is a dense-vector similarity index over stored (id, vector) pairs.
// Index 是建立在已存 (id, vector) 对上的稠密向量相似度索引。
//
// Embed turns a batch of texts into vectors using the wired embedder. Add
// persists an (id, vector) pair, upserting on a repeated id. Search returns the
// topK ids most similar to q, ranked by descending similarity. Dim reports the
// fixed vector dimension. Close releases the underlying database.
// Embed 用接入的 embedder 把一批文本转成向量。Add
// 持久化一个 (id, vector) 对,id 重复时 upsert。Search 返回与 q
// 最相似的前 topK 个 id,按相似度降序排列。Dim 报告
// 固定向量维度。Close 释放底层数据库。
type Index interface {
Embed(ctx context.Context, texts []string) ([]Vector, error)
Add(ctx context.Context, id string, v Vector) error
@@ -57,51 +57,51 @@ type Index interface {
Close() error
}
// Package-level sentinel errors. Each is wrapped with %w at the call site so
// callers can use errors.Is.
// 包级哨兵错误。每个都在调用点用 %w 包裹,调用方
// 可用 errors.Is 判断。
var (
// ErrNoEmbedder is returned when an index is asked to Embed but no embedder
// has been wired in (e.g. a constructor called directly rather than via New).
// ErrNoEmbedder 在索引被要求 Embed 却没有接入 embedder
// 时返回(例如直接调用构造函数而非经 New)。
ErrNoEmbedder = errors.New("memory/vector: no embedder configured")
// ErrNilDB is returned when a constructor receives a nil *sql.DB.
// ErrNilDB 在构造函数收到 nil *sql.DB 时返回。
ErrNilDB = errors.New("memory/vector: nil db")
// ErrInvalidDim is returned when a dimension is not positive.
// ErrInvalidDim 在维度非正时返回。
ErrInvalidDim = errors.New("memory/vector: dimension must be positive")
// ErrDimMismatch is returned when an added or queried vector's length does
// not match the index dimension.
// ErrDimMismatch 在新增或查询的向量长度与
// 索引维度不匹配时返回。
ErrDimMismatch = errors.New("memory/vector: vector dimension mismatch")
// ErrMissingID is returned when an id is blank.
// ErrMissingID 在 id 为空时返回。
ErrMissingID = errors.New("memory/vector: id is required")
// ErrInvalidPGURL is returned when the PostgreSQL URL is blank.
// ErrInvalidPGURL 在 PostgreSQL URL 为空时返回。
ErrInvalidPGURL = errors.New("memory/vector: invalid postgres url")
)
// New builds the Index selected by cfg: PGVectorIndex when cfg.PGURL is set,
// otherwise SQLiteIndex. embedder must be non-nil and report a positive Dim();
// it is wired into the returned index so Index.Embed produces vectors with it.
// log may be nil; logs are tagged with source "memory/vector".
// New 构建 cfg 选定的 Index:设置了 cfg.PGURL 时为 PGVectorIndex,
// 否则为 SQLiteIndex。embedder 必须非 nil 且 Dim() 为正;
// 它被接入返回的索引,使 Index.Embed 用它产向量。
// log 可为 nil;日志带 source "memory/vector" 标签。
//
// SQLiteIndex is created on a connection opened at cfg.SQLitePath (":memory:"
// for an isolated test database or a file path). PGVectorIndex connects to
// cfg.PGURL with the pgx driver and requires a PostgreSQL server with the
// pgvector extension installed.
// SQLiteIndex 建立在 cfg.SQLitePath 打开的连接上(":memory:"
// 用于隔离的测试数据库,或一个文件路径)。PGVectorIndex 用
// pgx 驱动连接 cfg.PGURL,要求已安装
// pgvector 扩展的 PostgreSQL 服务。
//
// New performs one-time setup with a background context and has no ctx
// parameter by design; every subsequent operation accepts a context.Cancel.
// New 用后台 context 做一次性准备,按设计不接收 ctx
// 参数;后续每个操作都接受 context.Cancel。
func New(cfg config.DBConfig, embedder embedding.Embedder, log *slog.Logger) (Index, error) {
if embedder == nil {
return nil, ErrNoEmbedder
}
dim := embedder.Dim()
if dim <= 0 {
// API-backed embedders do not know their vector dimension until the first
// successful response. Probe once so the index is built with the real
// dimension instead of erroring out and falling back to in-memory.
// 走 API 的 embedder 在首次成功响应前不知道自己的向量维度。
// 探测一次,使索引以真实
// 维度构建,而不是报错并回退到内存内实现。
if _, err := embedder.Embed(context.Background(), []string{"probe"}); err != nil {
return nil, fmt.Errorf("memory/vector: probe embedder dim: %w", err)
}
@@ -118,8 +118,8 @@ func New(cfg config.DBConfig, embedder embedding.Embedder, log *slog.Logger) (In
idx.withEmbedder(embedder)
return idx, nil
}
// db.Open logs internally; hand it a sourced logger so its records carry
// provenance, while the raw log goes to NewSQLite which sources it itself.
// db.Open 内部会记日志;交给它一个带来源的 logger,使其记录携带
// 出处,而原始 log 传给 NewSQLite,由后者自行加来源。
d, err := db.Open(context.Background(), cfg.SQLitePath, sourcedLogger(log))
if err != nil {
return nil, fmt.Errorf("memory/vector: open sqlite: %w", err)
@@ -133,16 +133,16 @@ func New(cfg config.DBConfig, embedder embedding.Embedder, log *slog.Logger) (In
return idx, nil
}
// usePG reports whether cfg should select the PostgreSQL+pgvector backend.
// Surrounding whitespace counts as unset.
// usePG 报告 cfg 是否应选择 PostgreSQL+pgvector 后端。
// 首尾空白视为未设置。
func usePG(pgURL string) bool {
return strings.TrimSpace(pgURL) != ""
}
// sourcedLogger returns log, or a discard logger when nil, tagged with source
// "memory/vector" so every record carries provenance. It is idempotent in
// output: re-applying the same source value on an already-sourced logger yields
// the same emitted attribute.
// sourcedLogger 返回 log,nil 时返回丢弃型 logger,并打上 source
// "memory/vector" 标签,使每条记录都携带出处。它在
// 输出上幂等:对已带来源的 logger 再次施加相同 source 值,
// 得到的输出属性相同。
func sourcedLogger(log *slog.Logger) *slog.Logger {
if log == nil {
log = slog.New(slog.NewTextHandler(io.Discard, nil))
+12 -12
View File
@@ -13,14 +13,14 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// testLogger returns a logger that writes nowhere, keeping test output clean.
// testLogger 返回一个不写任何地方的 logger,保持测试输出干净。
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// openTestDB opens an isolated in-memory SQLite DB. Each call owns a private DB,
// so parallel tests are isolated by construction. The returned DB is closed by
// t.Cleanup.
// openTestDB 打开一个隔离的内存 SQLite 库。每次调用都拥有私有 DB,
// 因此并行测试天生相互隔离。返回的 DB 由
// t.Cleanup 关闭。
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
@@ -32,8 +32,8 @@ func openTestDB(t *testing.T) *sql.DB {
return d
}
// newInMemorySQLite builds a SQLiteIndex on a fresh in-memory DB with dim
// dimensions. The embedder is left unwired so tests control it.
// newInMemorySQLite 在全新的内存 DB 上构建维度为 dim 的 SQLiteIndex。
// embedder 不接入,由测试自行控制。
func newInMemorySQLite(t *testing.T, dim int) *SQLiteIndex {
t.Helper()
ctx := context.Background()
@@ -44,8 +44,8 @@ func newInMemorySQLite(t *testing.T, dim int) *SQLiteIndex {
return idx
}
// mockEmbedder is a scriptable embedding.Embedder for tests. With no fn it
// returns one-hot vectors, one per input text, so tests can predict results.
// mockEmbedder 是测试用的可脚本化 embedding.Embedder。未设置 fn 时它
// 返回 one-hot 向量,每段输入文本一个,测试因此可预测结果。
type mockEmbedder struct {
dim int
fn func(ctx context.Context, texts []string) ([]embedding.Vector, error)
@@ -98,7 +98,7 @@ func TestNew_SelectsSQLite_WhenPGURLBlank(t *testing.T) {
if _, ok := idx.(*SQLiteIndex); !ok {
t.Fatalf("New type = %T, want *SQLiteIndex", idx)
}
// New wires the embedder, so Embed works on the returned index.
// New 接入了 embedder,因此返回的索引上 Embed 可用。
vs, err := idx.Embed(ctx, []string{"hello"})
if err != nil {
t.Fatalf("Embed: %v", err)
@@ -127,9 +127,9 @@ func TestNew_PGURLWhitespace_SelectsSQLite(t *testing.T) {
func TestNew_PGSelection_AttemptsPGWhenURLSet(t *testing.T) {
t.Parallel()
// A loopback URL on a closed port fails fast, proving New chose the PG path
// rather than silently falling back to SQLite. Port 1 (tcpmux) does not
// listen on localhost, so the connection is refused immediately.
// 指向已关闭端口的环回 URL 会快速失败,证明 New 选择了 PG 路径
// 而不是静默回退到 SQLite。端口 1(tcpmux)在
// localhost 上不监听,因此连接立即被拒。
idx, err := New(config.DBConfig{
SQLitePath: ":memory:",
PGURL: "postgres://127.0.0.1:1/mem?sslmode=disable&connect_timeout=1",
+47 -47
View File
@@ -10,25 +10,25 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
_ "github.com/jackc/pgx/v5/stdlib" // Postgres driver, registers name "pgx"
_ "github.com/jackc/pgx/v5/stdlib" // Postgres 驱动,注册名为 "pgx"
)
// pgMaxOpenConns is the PostgreSQL connection pool size mandated by
// DevDocs/02-development-constraints.md §2.2 for the pgvector enhancement.
// pgMaxOpenConns 是 DevDocs/02-development-constraints.md §2.2 为
// pgvector 增强规定的 PostgreSQL 连接池大小。
const pgMaxOpenConns = 25
// PGVectorIndex stores vectors in a PostgreSQL pgvector vector column and
// answers similarity with the cosine-distance operator <=> in SQL. It is the
// optional enhancement selected when DB_PG_URL is configured; when used,
// semantic search is genuinely backed by pgvector and does not fall back to
// keyword matching (DevDocs/design/03-记忆.md §3).
// PGVectorIndex 把向量存入 PostgreSQL 的 pgvector vector 列,并
// 用 SQL 中的余弦距离运算符 <=> 作答相似度。它是配置了
// DB_PG_URL 时选中的可选增强;启用时,
// 语义检索真正由 pgvector 支撑,不会退化为
// 关键词匹配(DevDocs/design/03-记忆.md §3)。
//
// The schema is prepared by NewPG (vector extension + vec_items table). Search
// issues ORDER BY vector <=> $1 LIMIT n, which returns exact cosine-distance-
// ranked results. With no ANN index the query planner does a sequential scan,
// which is the linear-scan behavior expected for milestone M4 (small datasets).
// An HNSW index (CREATE INDEX ... USING hnsw (vector vector_cosine_ops)) is the
// documented ANN upgrade for larger datasets and requires no change to Search.
// schema 由 NewPG 准备(vector 扩展 + vec_items 表)。Search
// 发出 ORDER BY vector <=> $1 LIMIT n,返回精确的余弦距离
// 排名结果。没有 ANN 索引时,查询规划器做顺序扫描,
// 这正是 M4 里程碑(小数据集)期望的线性扫描行为。
// HNSW 索引(CREATE INDEX ... USING hnsw (vector vector_cosine_ops))是
// 大数据集下既定的 ANN 升级方案,且无需改动 Search。
type PGVectorIndex struct {
db *sql.DB
dim int
@@ -36,16 +36,16 @@ type PGVectorIndex struct {
log *slog.Logger
}
// NewPG connects to the PostgreSQL+pgvector database at pgURL using the pgx
// driver and prepares the schema: the vector extension and a vec_items table
// with a fixed-dimension vector column. It deliberately creates no ANN index:
// for milestone M4 the <=> query runs as an exact sequential scan, and an HNSW
// index is a documented, optional upgrade for larger data. A blank pgURL
// returns ErrInvalidPGURL, and a non-positive dim returns ErrInvalidDim, both
// before any network I/O. ctx controls the connection ping and schema setup.
// NewPG 用 pgx 驱动连接到 pgURL 的 PostgreSQL+pgvector 数据库
// 并准备 schema:vector 扩展与一张带固定维度 vector 列的
// vec_items 表。它刻意不创建 ANN 索引:
// M4 里程碑下 <=> 查询走精确顺序扫描,而 HNSW
// 索引是既定的大数据量可选升级。pgURL 为空
// 返回 ErrInvalidPGURL,dim 非正返回 ErrInvalidDim,两者都在
// 任何网络 I/O 之前。ctx 控制连接 ping 与 schema 准备。
//
// The returned index has no embedder wired in; use New to build a fully wired
// index, or wire one in before calling Embed.
// 返回的索引没有接入 embedder;用 New 构建完全接好的
// 索引,或在调用 Embed 前接入一个。
func NewPG(ctx context.Context, pgURL string, dim int, log *slog.Logger) (*PGVectorIndex, error) {
if strings.TrimSpace(pgURL) == "" {
return nil, ErrInvalidPGURL
@@ -74,16 +74,16 @@ func NewPG(ctx context.Context, pgURL string, dim int, log *slog.Logger) (*PGVec
return idx, nil
}
// setup creates the pgvector extension and the vec_items table idempotently.
// For milestone M4 no ANN index is created — <->/<=> queries fall back to an
// exact sequential scan, matching the linear-scan semantics of the SQLite path.
// To scale, add:
// setup 幂等地创建 pgvector 扩展与 vec_items 表。
// M4 里程碑不创建 ANN 索引 —— <->/<=> 查询退化为
// 精确顺序扫描,与 SQLite 路径的线性扫描语义一致。
// 要扩容,可添加:
//
// CREATE INDEX IF NOT EXISTS idx_vec_items_vector
// ON vec_items USING hnsw (vector vector_cosine_ops)
//
// which requires no change to Search. Each statement is parameterized where it
// takes input; the table DDL is static after dimension substitution.
// 且无需改动 Search。每条语句在取输入处都参数化;
// 表 DDL 在维度替换后是静态的。
func (p *PGVectorIndex) setup(ctx context.Context) error {
statements := []string{
"CREATE EXTENSION IF NOT EXISTS vector",
@@ -101,16 +101,16 @@ func (p *PGVectorIndex) setup(ctx context.Context) error {
return nil
}
// withEmbedder wires the vector-producing embedder into the index. Used by New;
// tests may also call it directly.
// withEmbedder 把产向量的 embedder 接入索引。New 使用它;
// 测试也可直接调用。
func (p *PGVectorIndex) withEmbedder(e embedding.Embedder) { p.embedder = e }
// Dim returns the fixed vector dimension.
// Dim 返回固定向量维度。
func (p *PGVectorIndex) Dim() int { return p.dim }
// Embed turns texts into vectors with the wired embedder, returning one Vector
// per input text aligned by index. With no embedder wired it returns
// ErrNoEmbedder.
// Embed 用接入的 embedder 把 texts 转成向量,每段输入文本
// 对应一个按索引对齐的 Vector。未接入 embedder 时返回
// ErrNoEmbedder。
func (p *PGVectorIndex) Embed(ctx context.Context, texts []string) ([]Vector, error) {
if p.embedder == nil {
return nil, ErrNoEmbedder
@@ -126,10 +126,10 @@ func (p *PGVectorIndex) Embed(ctx context.Context, texts []string) ([]Vector, er
return out, nil
}
// Add persists the (id, vector) pair, upserting on a repeated id. The vector is
// passed to Postgres as a vector literal bound to a $n::vector parameter. It
// rejects a blank id (ErrMissingID) and a vector whose length differs from the
// index dimension (ErrDimMismatch).
// Add 持久化 (id, vector) 对,id 重复时 upsert。向量以
// 绑定到 $n::vector 参数的向量字面量传给 Postgres。它
// 拒绝空 id(ErrMissingID)以及与索引维度不一致的
// 向量(ErrDimMismatch)。
func (p *PGVectorIndex) Add(ctx context.Context, id string, v Vector) error {
if strings.TrimSpace(id) == "" {
return ErrMissingID
@@ -147,10 +147,10 @@ func (p *PGVectorIndex) Add(ctx context.Context, id string, v Vector) error {
return nil
}
// Search returns up to topK ids most similar to q, ranked by descending cosine
// similarity (1 - cosine distance), computed by pgvector in SQL. A non-positive
// topK returns an empty result; a query whose length differs from the index
// dimension returns ErrDimMismatch.
// Search 返回与 q 最相似的最多 topK 个 id,按余弦相似度
// (1 - 余弦距离)降序排列,由 pgvector 在 SQL 中计算。topK 非正
// 返回空结果;长度与索引维度不一致的查询
// 返回 ErrDimMismatch。
func (p *PGVectorIndex) Search(ctx context.Context, q Vector, topK int) ([]Hit, error) {
if topK <= 0 {
return []Hit{}, nil
@@ -184,7 +184,7 @@ func (p *PGVectorIndex) Search(ctx context.Context, q Vector, topK int) ([]Hit,
return hits, nil
}
// Close releases the underlying PostgreSQL connection pool.
// Close 释放底层的 PostgreSQL 连接池。
func (p *PGVectorIndex) Close() error {
if p.db == nil {
return nil
@@ -196,9 +196,9 @@ func (p *PGVectorIndex) Close() error {
return nil
}
// pgVectorLiteral renders v in pgvector's text form "[a,b,c]" so it can be
// bound as a vector parameter ($n::vector). Values are formatted with enough
// precision to round-trip a float32.
// pgVectorLiteral 把 v 渲染成 pgvector 的文本形式 "[a,b,c]",
// 以便绑定为向量参数($n::vector)。取值的格式化精度足以
// 往返一个 float32。
func pgVectorLiteral(v Vector) string {
var b strings.Builder
b.WriteByte('[')
+7 -7
View File
@@ -16,7 +16,7 @@ func TestNewPG_InvalidURL_异常(t *testing.T) {
func TestNewPG_InvalidDim_异常(t *testing.T) {
t.Parallel()
// dim is validated before any connection is opened, so no network I/O here.
// dim 在任何连接打开前就校验,因此这里没有网络 I/O。
if _, err := NewPG(context.Background(), "postgres://host/db", 0, testLogger()); !errors.Is(err, ErrInvalidDim) {
t.Fatalf("NewPG(dim 0) err = %v, want ErrInvalidDim", err)
}
@@ -44,11 +44,11 @@ func TestPGVectorLiteral(t *testing.T) {
}
}
// TestPG_RequiresEnv runs a real PostgreSQL+pgvector round trip when DB_PG_URL
// is set, and skips otherwise. There is no PG server in this environment by
// default, so the pgvector-backed Add/Search path is validated end-to-end only
// when the operator supplies a live database with the pgvector extension. The
// SQLite path (the default) is fully covered by the sqlite_test.go suite.
// TestPG_RequiresEnv 在设置了 DB_PG_URL 时跑一次真实的
// PostgreSQL+pgvector 往返,否则跳过。本环境默认没有 PG
// 服务,因此由 pgvector 支撑的 Add/Search 路径只有在
// 操作者提供带 pgvector 扩展的活数据库时才端到端验证。
// SQLite 路径(默认)由 sqlite_test.go 套件完整覆盖。
func TestPG_RequiresEnv(t *testing.T) {
url := os.Getenv("DB_PG_URL")
if url == "" {
@@ -81,7 +81,7 @@ func TestPG_RequiresEnv(t *testing.T) {
t.Fatalf("pg a score = %v, want ~1.0", hits[0].Score)
}
// The embedder is wired, so Index.Embed is usable.
// embedder 已接入,因此 Index.Embed 可用。
vs, err := idx.Embed(ctx, []string{"hello"})
if err != nil {
t.Fatalf("Embed: %v", err)
+41 -41
View File
@@ -13,11 +13,11 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// createVecItemsTable is the idempotent SQLite schema. vector is stored as a
// BLOB of little-endian float32 values; dim records the dimension so the blob
// can be decoded without external knowledge. The table is created by
// NewSQLite; migration compatibility with internal/db is not required because
// the vector store is self-contained (see internal/runtime/memory/store.go).
// createVecItemsTable 是幂等的 SQLite schema。vector 以小端
// float32 值的 BLOB 存储;dim 记录维度,因此 blob
// 无需外部信息即可解码。该表由
// NewSQLite 创建;不要求与 internal/db 的迁移兼容,因为
// 向量存储是自包含的(见 internal/runtime/memory/store.go)。
const createVecItemsTable = `
CREATE TABLE IF NOT EXISTS vec_items (
id TEXT PRIMARY KEY,
@@ -25,13 +25,13 @@ CREATE TABLE IF NOT EXISTS vec_items (
dim INTEGER NOT NULL
)`
// SQLiteIndex stores vectors as BLOBs in a SQLite vec_items table and answers
// similarity with Go-side cosine similarity over a linear scan (milestone M4
// semantics: small datasets, no ANN index).
// SQLiteIndex 把向量以 BLOB 存入 SQLite 的 vec_items 表,并以
// Go 侧线性扫描的余弦相似度作答相似度(M4 里程碑
// 语义:小数据集,无 ANN 索引)。
//
// Concurrency is delegated to the *sql.DB, which is safe for concurrent use by
// contract, so SQLiteIndex holds no further lock. Every query is parameterized
// (?) and columns are listed explicitly.
// 并发交由 *sql.DB,按约定它可安全并发使用,
// 因此 SQLiteIndex 不再持锁。每个查询都参数化
// (?)且列清单显式列出。
type SQLiteIndex struct {
db *sql.DB
dim int
@@ -39,14 +39,14 @@ type SQLiteIndex struct {
log *slog.Logger
}
// NewSQLite builds a SQLiteIndex on db for vectors of dimension dim, ensuring
// the vec_items schema exists. db must be a live connection; a nil db returns
// ErrNilDB, and a non-positive dim returns ErrInvalidDim. ctx controls schema
// creation. log may be nil.
// NewSQLite 在 db 上为维度 dim 的向量构建 SQLiteIndex,确保
// vec_items schema 存在。db 必须是存活连接;nil db 返回
// ErrNilDB,dim 非正返回 ErrInvalidDim。ctx 控制 schema
// 创建。log 可为 nil。
//
// The returned index has no embedder wired in, so calling Embed directly
// returns ErrNoEmbedder; use New to build a fully wired index, or wire one in
// before calling Embed.
// 返回的索引没有接入 embedder,因此直接调用 Embed
// 会返回 ErrNoEmbedder;用 New 构建完全接好的索引,或在调用
// Embed 前接入一个。
func NewSQLite(ctx context.Context, db *sql.DB, dim int, log *slog.Logger) (*SQLiteIndex, error) {
if db == nil {
return nil, ErrNilDB
@@ -62,16 +62,16 @@ func NewSQLite(ctx context.Context, db *sql.DB, dim int, log *slog.Logger) (*SQL
return &SQLiteIndex{db: db, dim: dim, log: log}, nil
}
// withEmbedder wires the vector-producing embedder into the index. It is used
// by New; tests may also call it directly.
// withEmbedder 把产向量的 embedder 接入索引。它由
// New 使用;测试也可直接调用。
func (s *SQLiteIndex) withEmbedder(e embedding.Embedder) { s.embedder = e }
// Dim returns the fixed vector dimension.
// Dim 返回固定向量维度。
func (s *SQLiteIndex) Dim() int { return s.dim }
// Embed turns texts into vectors with the wired embedder. It returns one Vector
// per input text, aligned by index. With no embedder wired it returns
// ErrNoEmbedder.
// Embed 用接入的 embedder 把 texts 转成向量。每段输入文本
// 对应一个按索引对齐的 Vector。未接入 embedder 时返回
// ErrNoEmbedder。
func (s *SQLiteIndex) Embed(ctx context.Context, texts []string) ([]Vector, error) {
if s.embedder == nil {
return nil, ErrNoEmbedder
@@ -87,10 +87,10 @@ func (s *SQLiteIndex) Embed(ctx context.Context, texts []string) ([]Vector, erro
return out, nil
}
// Add persists the (id, vector) pair, upserting on a repeated id so the
// latest vector wins. It rejects a blank id (ErrMissingID) and a vector whose
// length differs from the index dimension (ErrDimMismatch). ctx aborts the
// write when canceled.
// Add 持久化 (id, vector) 对,id 重复时 upsert,
// 最新的向量生效。它拒绝空 id(ErrMissingID)以及与
// 索引维度不一致的向量(ErrDimMismatch)。ctx 被取消时
// 中止写入。
func (s *SQLiteIndex) Add(ctx context.Context, id string, v Vector) error {
if strings.TrimSpace(id) == "" {
return ErrMissingID
@@ -108,10 +108,10 @@ func (s *SQLiteIndex) Add(ctx context.Context, id string, v Vector) error {
return nil
}
// Search returns up to topK ids most similar to q, ranked by descending cosine
// similarity. It scans every persisted vector in Go (linear scan, no ANN). A
// non-positive topK returns an empty result; a query whose length differs from
// the index dimension returns ErrDimMismatch.
// Search 返回与 q 最相似的最多 topK 个 id,按余弦相似度
// 降序排列。它在 Go 侧扫描每个已持久化向量(线性扫描,无 ANN)。topK
// 非正返回空结果;长度与
// 索引维度不一致的查询返回 ErrDimMismatch。
func (s *SQLiteIndex) Search(ctx context.Context, q Vector, topK int) ([]Hit, error) {
if topK <= 0 {
return []Hit{}, nil
@@ -151,8 +151,8 @@ func (s *SQLiteIndex) Search(ctx context.Context, q Vector, topK int) ([]Hit, er
return hits, nil
}
// Close releases the underlying SQLite database. It is safe to call when the
// database is already closed.
// Close 释放底层的 SQLite 数据库。数据库
// 已关闭时调用也是安全的。
func (s *SQLiteIndex) Close() error {
if s.db == nil {
return nil
@@ -164,7 +164,7 @@ func (s *SQLiteIndex) Close() error {
return nil
}
// encodeVector serializes v to a little-endian float32 BLOB. It cannot fail.
// encodeVector 把 v 序列化为小端 float32 BLOB。它不会失败。
func encodeVector(v Vector) []byte {
buf := make([]byte, 4*len(v))
for i, x := range v {
@@ -173,9 +173,9 @@ func encodeVector(v Vector) []byte {
return buf
}
// decodeVector deserializes a BLOB back into a Vector. It rejects empty or
// non-4-byte-aligned blobs so a corrupted row surfaces as an error rather than
// a silent misread.
// decodeVector 把 BLOB 反序列化回 Vector。它拒绝空或
// 非 4 字节对齐的 blob,使损坏的行以错误暴露,而不是
// 被静默误读。
func decodeVector(blob []byte) (Vector, error) {
if len(blob) == 0 || len(blob)%4 != 0 {
return nil, fmt.Errorf("memory/vector: invalid vector blob length %d", len(blob))
@@ -187,9 +187,9 @@ func decodeVector(blob []byte) (Vector, error) {
return v, nil
}
// cosine returns the cosine similarity of a and b in [-1, 1]. Zero-length or
// mismatched vectors yield 0, so an inconsistent stored row never corrupts the
// ranking.
// cosine 返回 a 与 b 的余弦相似度,取值 [-1, 1]。长度为零或
// 不一致的向量得 0,因此不一致的存储行绝不会破坏
// 排名。
func cosine(a, b Vector) float64 {
if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
return 0
+32 -32
View File
@@ -9,28 +9,28 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// Working memory sizing and summarization constants.
// 工作记忆的窗口大小与摘要常量。
const (
// defaultWorkingWindow is the default number of messages retained in the
// sliding window when NewWorkingMemory is called with a non-positive size.
// defaultWorkingWindow 是 NewWorkingMemory 以非正 size
// 调用时滑动窗口默认保留的消息数。
defaultWorkingWindow = 20
// summaryLastTextMax is the maximum rune length of the trailing message text
// included in a naive summary, keeping self-summaries compact.
// summaryLastTextMax 是朴素摘要中所含末条消息文本
// 的最大 rune 数,用于保持自摘要紧凑。
summaryLastTextMax = 80
)
// WorkingMemory is the per-session short-term memory: a sliding window of the
// current conversation's messages plus a summary (design §0 layer ①). It is the
// immediate context an agent reasons over.
// WorkingMemory 是每会话的短期记忆:当前对话消息的
// 滑动窗口加一份摘要(design §0 layer ①)。它是
// 代理推理所依据的即时上下文。
//
// The window is bounded: adding beyond the maximum evicts the oldest message.
// Summarize returns the summary set via SetSummary, or a naive structural
// summary computed on demand. LLM-generated summaries are deferred to M4; the
// plumbing is in place so a summarizer can call SetSummary.
// 窗口有界:超出上限的添加会淘汰最旧的消息。
// Summarize 返回经 SetSummary 设置的摘要,或按需计算的
// 朴素结构化摘要。LLM 生成的摘要推迟到 M4;管线
// 已就位,摘要器可调用 SetSummary。
//
// WorkingMemory is safe for concurrent use; Add/SetSummary serialize with a
// write lock and Window/Summarize read under a read lock.
// WorkingMemory 并发安全;Add/SetSummary 以写锁串行,
// Window/Summarize 在读锁下读取。
type WorkingMemory struct {
mu sync.RWMutex
window []model.Message
@@ -38,8 +38,8 @@ type WorkingMemory struct {
summary string
}
// NewWorkingMemory builds a working memory with a sliding window of up to
// maxMessages messages. A non-positive maxMessages selects defaultWorkingWindow.
// NewWorkingMemory 构建一个工作记忆,滑动窗口最多
// maxMessages 条消息。非正 maxMessages 选取 defaultWorkingWindow。
func NewWorkingMemory(maxMessages int) *WorkingMemory {
if maxMessages <= 0 {
maxMessages = defaultWorkingWindow
@@ -47,8 +47,8 @@ func NewWorkingMemory(maxMessages int) *WorkingMemory {
return &WorkingMemory{max: maxMessages}
}
// Add appends msg to the window and evicts the oldest message when the window
// exceeds its limit.
// Add 把 msg 追加到窗口,并在窗口超出上限时
// 淘汰最旧的消息。
func (w *WorkingMemory) Add(msg model.Message) {
w.mu.Lock()
defer w.mu.Unlock()
@@ -61,9 +61,9 @@ func (w *WorkingMemory) Add(msg model.Message) {
}
}
// Window returns a deep copy of the current message window, oldest first. The
// returned slice and each message's content parts are detached from internal
// state, so callers may mutate them freely.
// Window 返回当前消息窗口的深拷贝,最旧的在前。
// 返回的切片与每条消息的 content 部分都与内部
// 状态脱离,因此调用方可以随意修改。
func (w *WorkingMemory) Window() []model.Message {
w.mu.RLock()
defer w.mu.RUnlock()
@@ -75,9 +75,9 @@ func (w *WorkingMemory) Window() []model.Message {
return out
}
// cloneMessage returns a copy of m whose content parts are detached from the
// source. Every field of model.Content is a value type, so copying the parts
// slice is sufficient.
// cloneMessage 返回 m 的副本,其 content 部分与源脱离。
// model.Content 的每个字段都是值类型,因此复制 parts
// 切片就足够了。
func cloneMessage(m model.Message) model.Message {
parts := make([]model.Content, len(m.Content))
copy(parts, m.Content)
@@ -85,16 +85,16 @@ func cloneMessage(m model.Message) model.Message {
return m
}
// SetSummary records an explicit summary (e.g. produced by an LLM in M4).
// SetSummary 记录一条显式摘要(例如 M4 中由 LLM 产出)。
func (w *WorkingMemory) SetSummary(s string) {
w.mu.Lock()
defer w.mu.Unlock()
w.summary = s
}
// Summarize returns the stored summary when one was set, otherwise a naive
// structural summary derived from the current window. It never mutates state, so
// repeated calls return a value consistent with the window at call time.
// Summarize 在已设置摘要时返回存储的摘要,否则返回由当前
// 窗口推导的朴素结构化摘要。它从不修改状态,因此
// 重复调用返回与调用时窗口一致的值。
func (w *WorkingMemory) Summarize() string {
w.mu.RLock()
defer w.mu.RUnlock()
@@ -105,8 +105,8 @@ func (w *WorkingMemory) Summarize() string {
return w.naiveSummaryLocked()
}
// naiveSummaryLocked builds "{n} 条消息, {role} {count}, ...; 最近: {text}". It
// requires w.mu (read or write) to be held.
// naiveSummaryLocked 构造 "{n} 条消息, {role} {count}, ...; 最近: {text}"。
// 它要求持有 w.mu(读锁或写锁)。
func (w *WorkingMemory) naiveSummaryLocked() string {
if len(w.window) == 0 {
return ""
@@ -136,8 +136,8 @@ func (w *WorkingMemory) naiveSummaryLocked() string {
return b.String()
}
// truncateRunes shortens s to at most n runes. Unicode-safe: it never slices a
// multi-byte rune in half.
// truncateRunes 把 s 截到最多 n 个 rune。Unicode 安全:它绝不把
// 多字节 rune 切一半。
func truncateRunes(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
+1 -1
View File
@@ -20,7 +20,7 @@ func TestWorkingMemory_Window_Sliding_正常(t *testing.T) {
if len(window) != 3 {
t.Fatalf("Window length = %d, want 3", len(window))
}
// The oldest two messages are evicted; the last three remain.
// 最旧的两条消息被淘汰;后三条保留。
for i, want := range []string{"c", "d", "e"} {
if got := window[i].Content[0].Text; got != want {
t.Errorf("window[%d].Text = %q, want %q", i, got, want)