diff --git a/internal/runtime/memory/decay.go b/internal/runtime/memory/decay.go index d782283..79491c5 100644 --- a/internal/runtime/memory/decay.go +++ b/internal/runtime/memory/decay.go @@ -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 { diff --git a/internal/runtime/memory/decay_test.go b/internal/runtime/memory/decay_test.go index 8d8f95f..054c17a 100644 --- a/internal/runtime/memory/decay_test.go +++ b/internal/runtime/memory/decay_test.go @@ -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) diff --git a/internal/runtime/memory/extract.go b/internal/runtime/memory/extract.go index a531e10..81d7f7f 100644 --- a/internal/runtime/memory/extract.go +++ b/internal/runtime/memory/extract.go @@ -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) { diff --git a/internal/runtime/memory/extract_test.go b/internal/runtime/memory/extract_test.go index e2cb8a4..c80bbfe 100644 --- a/internal/runtime/memory/extract_test.go +++ b/internal/runtime/memory/extract_test.go @@ -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) } diff --git a/internal/runtime/memory/llmextractor.go b/internal/runtime/memory/llmextractor.go index 0542fc5..f7b85eb 100644 --- a/internal/runtime/memory/llmextractor.go +++ b/internal/runtime/memory/llmextractor.go @@ -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 diff --git a/internal/runtime/memory/llmextractor_test.go b/internal/runtime/memory/llmextractor_test.go index 7eff43c..698a9e8 100644 --- a/internal/runtime/memory/llmextractor_test.go +++ b/internal/runtime/memory/llmextractor_test.go @@ -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) diff --git a/internal/runtime/memory/memory_test.go b/internal/runtime/memory/memory_test.go index 0cdd4f1..3318208 100644 --- a/internal/runtime/memory/memory_test.go +++ b/internal/runtime/memory/memory_test.go @@ -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", diff --git a/internal/runtime/memory/message.go b/internal/runtime/memory/message.go index 8225291..a2ce8f3 100644 --- a/internal/runtime/memory/message.go +++ b/internal/runtime/memory/message.go @@ -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 { diff --git a/internal/runtime/memory/projectindex/chapter.go b/internal/runtime/memory/projectindex/chapter.go index ac2b725..8481cff 100644 --- a/internal/runtime/memory/projectindex/chapter.go +++ b/internal/runtime/memory/projectindex/chapter.go @@ -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 diff --git a/internal/runtime/memory/projectindex/project.go b/internal/runtime/memory/projectindex/project.go index 4c32c4b..993ab4a 100644 --- a/internal/runtime/memory/projectindex/project.go +++ b/internal/runtime/memory/projectindex/project.go @@ -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 { diff --git a/internal/runtime/memory/projectindex/projectindex_test.go b/internal/runtime/memory/projectindex/projectindex_test.go index 4f997f2..73964d7 100644 --- a/internal/runtime/memory/projectindex/projectindex_test.go +++ b/internal/runtime/memory/projectindex/projectindex_test.go @@ -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) } diff --git a/internal/runtime/memory/projectindex/search.go b/internal/runtime/memory/projectindex/search.go index 8bcdd29..dcabbf5 100644 --- a/internal/runtime/memory/projectindex/search.go +++ b/internal/runtime/memory/projectindex/search.go @@ -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 { diff --git a/internal/runtime/memory/projectindex/store.go b/internal/runtime/memory/projectindex/store.go index f59a4d3..e52accc 100644 --- a/internal/runtime/memory/projectindex/store.go +++ b/internal/runtime/memory/projectindex/store.go @@ -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, diff --git a/internal/runtime/memory/projectindex/timefmt.go b/internal/runtime/memory/projectindex/timefmt.go index 053c8c5..0fc35bd 100644 --- a/internal/runtime/memory/projectindex/timefmt.go +++ b/internal/runtime/memory/projectindex/timefmt.go @@ -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 diff --git a/internal/runtime/memory/projectindex/timefmt_test.go b/internal/runtime/memory/projectindex/timefmt_test.go index 617e65e..d00ce80 100644 --- a/internal/runtime/memory/projectindex/timefmt_test.go +++ b/internal/runtime/memory/projectindex/timefmt_test.go @@ -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) } diff --git a/internal/runtime/memory/retrieve.go b/internal/runtime/memory/retrieve.go index 97c0381..4f9a733 100644 --- a/internal/runtime/memory/retrieve.go +++ b/internal/runtime/memory/retrieve.go @@ -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 { diff --git a/internal/runtime/memory/retrieve_m4_test.go b/internal/runtime/memory/retrieve_m4_test.go index 4fa100a..114d377 100644 --- a/internal/runtime/memory/retrieve_m4_test.go +++ b/internal/runtime/memory/retrieve_m4_test.go @@ -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 { diff --git a/internal/runtime/memory/retrieve_test.go b/internal/runtime/memory/retrieve_test.go index 4a8b225..01d49ef 100644 --- a/internal/runtime/memory/retrieve_test.go +++ b/internal/runtime/memory/retrieve_test.go @@ -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) } diff --git a/internal/runtime/memory/rules.go b/internal/runtime/memory/rules.go index 2aebe83..428589a 100644 --- a/internal/runtime/memory/rules.go +++ b/internal/runtime/memory/rules.go @@ -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 } diff --git a/internal/runtime/memory/rules_test.go b/internal/runtime/memory/rules_test.go index a6e3b6d..9e46201 100644 --- a/internal/runtime/memory/rules_test.go +++ b/internal/runtime/memory/rules_test.go @@ -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) } diff --git a/internal/runtime/memory/store.go b/internal/runtime/memory/store.go index 2809050..b19a7b0 100644 --- a/internal/runtime/memory/store.go +++ b/internal/runtime/memory/store.go @@ -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, diff --git a/internal/runtime/memory/store_test.go b/internal/runtime/memory/store_test.go index 0a84630..b7dfc56 100644 --- a/internal/runtime/memory/store_test.go +++ b/internal/runtime/memory/store_test.go @@ -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() diff --git a/internal/runtime/memory/timefmt.go b/internal/runtime/memory/timefmt.go index fafa0c8..264d958 100644 --- a/internal/runtime/memory/timefmt.go +++ b/internal/runtime/memory/timefmt.go @@ -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 diff --git a/internal/runtime/memory/timefmt_test.go b/internal/runtime/memory/timefmt_test.go index 92d1a60..513151b 100644 --- a/internal/runtime/memory/timefmt_test.go +++ b/internal/runtime/memory/timefmt_test.go @@ -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() diff --git a/internal/runtime/memory/vector/index.go b/internal/runtime/memory/vector/index.go index 508181d..4d11bf1 100644 --- a/internal/runtime/memory/vector/index.go +++ b/internal/runtime/memory/vector/index.go @@ -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)) diff --git a/internal/runtime/memory/vector/index_test.go b/internal/runtime/memory/vector/index_test.go index 61623d2..0944d88 100644 --- a/internal/runtime/memory/vector/index_test.go +++ b/internal/runtime/memory/vector/index_test.go @@ -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", diff --git a/internal/runtime/memory/vector/pg.go b/internal/runtime/memory/vector/pg.go index da0fd08..14fcced 100644 --- a/internal/runtime/memory/vector/pg.go +++ b/internal/runtime/memory/vector/pg.go @@ -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('[') diff --git a/internal/runtime/memory/vector/pg_test.go b/internal/runtime/memory/vector/pg_test.go index f4e4b50..1cfcd5c 100644 --- a/internal/runtime/memory/vector/pg_test.go +++ b/internal/runtime/memory/vector/pg_test.go @@ -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) diff --git a/internal/runtime/memory/vector/sqlite.go b/internal/runtime/memory/vector/sqlite.go index 71c5d30..4c0ba38 100644 --- a/internal/runtime/memory/vector/sqlite.go +++ b/internal/runtime/memory/vector/sqlite.go @@ -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 diff --git a/internal/runtime/memory/working.go b/internal/runtime/memory/working.go index d29f04e..0256266 100644 --- a/internal/runtime/memory/working.go +++ b/internal/runtime/memory/working.go @@ -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 diff --git a/internal/runtime/memory/working_test.go b/internal/runtime/memory/working_test.go index da3ec0f..5049c03 100644 --- a/internal/runtime/memory/working_test.go +++ b/internal/runtime/memory/working_test.go @@ -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)