- 只翻注释/文档字符串:820 增 / 820 删 严格 1:1;剥掉整行注释与行尾注释尾巴后, 逐文件与 HEAD 比对「代码部分差异 = 0」;裁决/design 编号计数逐项与 HEAD 一致 - 保留可执行 SQL 样例、SQLite 报错原文、pgvector/HNSW/ANN/BLOB 等技术词原文 - 复核:go build 绿 + memory/projectindex/vector 三包测试绿;gofmt 仅剩 2 个既有未格式化文件(改动前即如此,未顺手修)
165 lines
5.9 KiB
Go
165 lines
5.9 KiB
Go
// projectindex 包实现 DevDocs/design/03-记忆.md §3(「项目记忆」作用域)中的
|
||
// 项目索引层级:一个项目被索引为一条
|
||
// 条目加描述,其内容被切成可逐行检索的章节。
|
||
// 三层深度为 项目索引 → 项目描述 → 章节内容。
|
||
//
|
||
// 两张表刻画该深度:project 行是指引条目(第 1 层),
|
||
// 携带其描述(第 2 层),project_chapter 保存章节
|
||
// 内容(第 3 层)。项目记忆每个角色都可读写
|
||
// (design §3「人人可读可写」「项目索引各角色可查」),因此存储没有
|
||
// persona/role 列,也不在此按身份过滤。
|
||
//
|
||
// 本包刻意与向量索引
|
||
// (internal/runtime/memory/vector)分离:它对项目条目、描述与章节
|
||
// 做基于文本的分层索引。语义/向量检索属于
|
||
// 向量包。
|
||
//
|
||
// 存储自己拥有 schema:New 幂等地创建 project 与 project_chapter 表
|
||
// 及章节索引。所有 SQL 参数化(?),列清单
|
||
// 显式列出(无 SELECT *),读取带 context.Cancel,且每个错误都
|
||
// 用 %w 包裹,调用方可用 errors.Is 对包哨兵错误判断。
|
||
package projectindex
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
)
|
||
|
||
// 区分 projectindex 各类失败的哨兵错误。每个都在
|
||
// 调用点用 %w 包裹,调用方可用 errors.Is 判断。
|
||
var (
|
||
// ErrProjectNotFound 在项目 id 为空或不存在时返回。
|
||
ErrProjectNotFound = errors.New("projectindex: project not found")
|
||
|
||
// ErrChapterNotFound 在必需的章节引用无效时返回。
|
||
ErrChapterNotFound = errors.New("projectindex: chapter not found")
|
||
|
||
// ErrNilDB 在 New 收到 nil *sql.DB 时返回。
|
||
ErrNilDB = errors.New("projectindex: nil db")
|
||
|
||
// ErrMissingProject 在必需的 project id 为空时返回。
|
||
ErrMissingProject = errors.New("projectindex: project_id is required")
|
||
|
||
// ErrMissingTitle 在项目或章节标题为空时返回。
|
||
ErrMissingTitle = errors.New("projectindex: title is required")
|
||
|
||
// ErrMissingContent 在章节没有内容时返回。
|
||
ErrMissingContent = errors.New("projectindex: content is required")
|
||
|
||
// ErrMissingKeyword 在 Search 收到空关键词时返回。
|
||
ErrMissingKeyword = errors.New("projectindex: keyword is required")
|
||
)
|
||
|
||
// Store 是基于 *sql.DB 的项目索引存储。
|
||
//
|
||
// 它自己拥有 schema:New 幂等地创建 project / project_chapter 表与
|
||
// 章节索引。并发交由 *sql.DB,按约定它可安全
|
||
// 并发使用,因此 Store 不再持锁。每个查询都
|
||
// 参数化,读取带 context.Cancel。
|
||
type Store struct {
|
||
db *sql.DB
|
||
log *slog.Logger
|
||
}
|
||
|
||
// rowScanner 同时被 *sql.Row 与 *sql.Rows 满足,因此单个 scanProject
|
||
// 或 scanChapter 辅助函数即可服务 Get 与 List 两条路径。
|
||
type rowScanner interface {
|
||
Scan(dest ...any) error
|
||
}
|
||
|
||
// New 在 db 上构建 Store,确保 schema 存在。
|
||
//
|
||
// db 必须是存活的连接(迁移框架由
|
||
// internal/db 拥有)。nil db 返回 ErrNilDB。log 可为 nil,此时
|
||
// 使用丢弃型 logger;实际使用的 logger 带 source
|
||
// "memory/projectindex" 标签,因此每条记录都可追溯。ctx 控制 schema
|
||
// 创建,调用方必须遵守。旧的变宽时间戳
|
||
// 就地规范化(见 normalizeTimestamps)。
|
||
func New(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
|
||
if db == nil {
|
||
return nil, ErrNilDB
|
||
}
|
||
log = logger.WithSource(discardLogger(log), "memory/projectindex")
|
||
for _, ddl := range []string{
|
||
createProjectTable,
|
||
createProjectChapterTable,
|
||
createProjectChapterIndex,
|
||
} {
|
||
if _, err := db.ExecContext(ctx, ddl); err != nil {
|
||
return nil, fmt.Errorf("projectindex: apply schema: %w", err)
|
||
}
|
||
}
|
||
if err := normalizeTimestamps(ctx, db); err != nil {
|
||
return nil, err
|
||
}
|
||
return &Store{db: db, log: log}, nil
|
||
}
|
||
|
||
// discardLogger 在 log 非 nil 时返回 log,否则返回一个不写任何地方的 logger。
|
||
func discardLogger(log *slog.Logger) *slog.Logger {
|
||
if log != nil {
|
||
return log
|
||
}
|
||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||
}
|
||
|
||
// ListProjects 按插入顺序(created_at ASC、rowid ASC)返回每个
|
||
// 项目索引条目 —— rowid 打破 created_at 并列,使同一时钟刻内写入的项目
|
||
// 保持其插入顺序。没有条目时返回空的
|
||
// (非 nil)切片,调用方可以无条件 range。读
|
||
// 错误用 %w 包裹,调用方可用 errors.Is 对包
|
||
// 哨兵错误判断。
|
||
func (s *Store) ListProjects(ctx context.Context) ([]Project, error) {
|
||
rows, err := s.db.QueryContext(ctx, `SELECT id, title, desc, created_at, updated_at
|
||
FROM project ORDER BY created_at ASC, rowid ASC`)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("projectindex: list projects: %w", err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
projects := make([]Project, 0)
|
||
for rows.Next() {
|
||
p, err := scanProject(rows)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("projectindex: scan project: %w", err)
|
||
}
|
||
projects = append(projects, *p)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("projectindex: iterate projects: %w", err)
|
||
}
|
||
return projects, nil
|
||
}
|
||
|
||
// schema DDL,由 New 幂等施加。两张表刻画三层
|
||
// 层级:project 行是指引条目(第 1 层)加其描述
|
||
// (第 2 层),project_chapter 保存章节内容(第 3 层)。
|
||
const createProjectTable = `
|
||
CREATE TABLE IF NOT EXISTS project (
|
||
id TEXT PRIMARY KEY,
|
||
title TEXT NOT NULL,
|
||
desc TEXT NOT NULL DEFAULT '',
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
)`
|
||
|
||
const createProjectChapterTable = `
|
||
CREATE TABLE IF NOT EXISTS project_chapter (
|
||
id TEXT PRIMARY KEY,
|
||
project_id TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
)`
|
||
|
||
const createProjectChapterIndex = `
|
||
CREATE INDEX IF NOT EXISTS idx_project_chapter_project_id
|
||
ON project_chapter (project_id)`
|