feat(memory/projectindex): 项目索引分层(索引+描述+章节,Search 定位行+上下文,级联删除)
This commit is contained in:
1 parent
d90ec7bd4f
commit
ef79edd96a
5 files changed
+1010
No files matched your search
@@ -0,0 +1,132 @@
|
||||
// Package projectindex implements the project-index hierarchy from
|
||||
// DevDocs/design/03-记忆.md §3 ("项目记忆"作用域): a project is indexed as an
|
||||
// entry plus description, and its content is split into chapters that can be
|
||||
// searched line by line. The three-layer depth is 项目索引 → 项目描述 → 章节内容.
|
||||
//
|
||||
// The two tables model that depth: the project row is the index entry (layer 1)
|
||||
// carrying its description (layer 2), and project_chapter holds the chapter
|
||||
// content (layer 3). Project memory is readable and writable by every persona
|
||||
// (design §3 "人人可读可写", "项目索引各角色可查"), so the store carries no
|
||||
// persona/role column and never filters by identity here.
|
||||
//
|
||||
// This package is deliberately separate from the vector index
|
||||
// (internal/runtime/memory/vector): it does text-based hierarchical indexing of
|
||||
// project entries, descriptions, and chapters. Semantic/vector search belongs to
|
||||
// the vector package.
|
||||
//
|
||||
// The store owns its schema: New creates the project and project_chapter tables
|
||||
// and the chapter index idempotently. All SQL is parameterized (?), column lists
|
||||
// are explicit (no SELECT *), reads take a context.Cancel, and every error is
|
||||
// wrapped with %w so callers can use errors.Is against the package sentinels.
|
||||
package projectindex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// Sentinels distinguishing projectindex failures. Each is wrapped with %w at the
|
||||
// call site so callers can use errors.Is.
|
||||
var (
|
||||
// ErrProjectNotFound is returned when a project id is blank or does not exist.
|
||||
ErrProjectNotFound = errors.New("projectindex: project not found")
|
||||
|
||||
// ErrChapterNotFound is returned when a required chapter reference is invalid.
|
||||
ErrChapterNotFound = errors.New("projectindex: chapter not found")
|
||||
|
||||
// ErrNilDB is returned when New receives a nil *sql.DB.
|
||||
ErrNilDB = errors.New("projectindex: nil db")
|
||||
|
||||
// ErrMissingProject is returned when a required project id is blank.
|
||||
ErrMissingProject = errors.New("projectindex: project_id is required")
|
||||
|
||||
// ErrMissingTitle is returned when a project or chapter title is blank.
|
||||
ErrMissingTitle = errors.New("projectindex: title is required")
|
||||
|
||||
// ErrMissingContent is returned when a chapter has no content.
|
||||
ErrMissingContent = errors.New("projectindex: content is required")
|
||||
|
||||
// ErrMissingKeyword is returned when Search receives a blank keyword.
|
||||
ErrMissingKeyword = errors.New("projectindex: keyword is required")
|
||||
)
|
||||
|
||||
// Store is a project-index store backed by a *sql.DB.
|
||||
//
|
||||
// It owns its schema: New creates the project / project_chapter tables and the
|
||||
// chapter index idempotently. Concurrency is delegated to *sql.DB, which is safe
|
||||
// for concurrent use by contract, so Store holds no further lock. Every query is
|
||||
// parameterized and reads take a context.Cancel.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// rowScanner is satisfied by both *sql.Row and *sql.Rows so a single scanProject
|
||||
// or scanChapter helper serves both the Get and List paths.
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// New builds a Store on db, ensuring the schema exists.
|
||||
//
|
||||
// db must be a live connection (the migration framework is owned by
|
||||
// internal/db). A nil db returns ErrNilDB. log may be nil, in which case a
|
||||
// discard logger is used; the active logger is tagged with source
|
||||
// "memory/projectindex" so every record is traceable. ctx controls schema
|
||||
// creation and must be honored by the caller.
|
||||
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)
|
||||
}
|
||||
}
|
||||
return &Store{db: db, log: log}, nil
|
||||
}
|
||||
|
||||
// discardLogger returns log if non-nil, otherwise a logger that writes nowhere.
|
||||
func discardLogger(log *slog.Logger) *slog.Logger {
|
||||
if log != nil {
|
||||
return log
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// Schema DDL, applied idempotently by New. The two tables model the three-layer
|
||||
// hierarchy: the project row is the index entry (layer 1) plus its description
|
||||
// (layer 2), and project_chapter holds the chapter content (layer 3).
|
||||
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)`
|
||||
Reference in new issue
Block a user