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

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

No files matched your search

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