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,110 @@
|
||||
package projectindex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Chapter is a project chapter (layer 3 of the hierarchy): the deep content a
|
||||
// project is split into. ProjectID links it to the owning project. The zero
|
||||
// value is not a valid chapter: AddChapter requires a non-blank title and
|
||||
// content, and generates the id.
|
||||
type Chapter struct {
|
||||
ID string
|
||||
ProjectID string
|
||||
Title string
|
||||
Content string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// AddChapter persists ch under projectID, assigning an id and missing
|
||||
// timestamps. It requires a non-blank projectID, title and content; a blank id
|
||||
// is generated. The project row itself does not have to exist, so chapters may
|
||||
// be added before or after their parent project, but DeleteProject cascades
|
||||
// them away together.
|
||||
func (s *Store) AddChapter(ctx context.Context, projectID string, ch Chapter) error {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return ErrMissingProject
|
||||
}
|
||||
if strings.TrimSpace(ch.Title) == "" {
|
||||
return ErrMissingTitle
|
||||
}
|
||||
if strings.TrimSpace(ch.Content) == "" {
|
||||
return ErrMissingContent
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if ch.ID == "" {
|
||||
id, err := newID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch.ID = id
|
||||
}
|
||||
if ch.CreatedAt.IsZero() {
|
||||
ch.CreatedAt = now
|
||||
}
|
||||
if ch.UpdatedAt.IsZero() {
|
||||
ch.UpdatedAt = now
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO project_chapter
|
||||
(id, project_id, title, content, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
ch.ID, projectID, ch.Title, ch.Content,
|
||||
formatTime(ch.CreatedAt), formatTime(ch.UpdatedAt))
|
||||
if err != nil {
|
||||
return fmt.Errorf("projectindex: insert chapter: %w", err)
|
||||
}
|
||||
s.log.Debug("chapter added", "project_id", projectID, "chapter_id", ch.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListChapters returns every chapter for projectID in insertion order. It returns
|
||||
// an empty (non-nil) slice when there are none; a blank projectID returns
|
||||
// ErrMissingProject.
|
||||
func (s *Store) ListChapters(ctx context.Context, projectID string) ([]Chapter, error) {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return nil, ErrMissingProject
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, project_id, title, content,
|
||||
created_at, updated_at FROM project_chapter
|
||||
WHERE project_id = ? ORDER BY created_at ASC, id ASC`, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("projectindex: list chapters: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
chapters := make([]Chapter, 0)
|
||||
for rows.Next() {
|
||||
ch, err := scanChapter(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("projectindex: scan chapter: %w", err)
|
||||
}
|
||||
chapters = append(chapters, ch)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("projectindex: iterate chapters: %w", err)
|
||||
}
|
||||
return chapters, nil
|
||||
}
|
||||
|
||||
// scanChapter reads one project_chapter row.
|
||||
func scanChapter(sc rowScanner) (Chapter, error) {
|
||||
var (
|
||||
ch Chapter
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
if err := sc.Scan(&ch.ID, &ch.ProjectID, &ch.Title, &ch.Content, &createdAt, &updatedAt); err != nil {
|
||||
return Chapter{}, err
|
||||
}
|
||||
var err error
|
||||
if ch.CreatedAt, err = parseTime(createdAt); err != nil {
|
||||
return Chapter{}, err
|
||||
}
|
||||
if ch.UpdatedAt, err = parseTime(updatedAt); err != nil {
|
||||
return Chapter{}, err
|
||||
}
|
||||
return ch, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package projectindex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Project is a project index entry (design §3): the project marker that links
|
||||
// its chapters, together with the project's description. It is layer 1 (index
|
||||
// entry) and layer 2 (description) of the hierarchy. The zero value is not a
|
||||
// valid project: ID and Title are required.
|
||||
type Project struct {
|
||||
ID string
|
||||
Title string
|
||||
Desc string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// PutProject upserts p by id. On an existing id it updates title, desc, and
|
||||
// updated_at while preserving the original created_at; on a new id it assigns
|
||||
// missing timestamps and inserts. A blank id or title returns ErrMissingProject
|
||||
// or ErrMissingTitle.
|
||||
func (s *Store) PutProject(ctx context.Context, p Project) error {
|
||||
if strings.TrimSpace(p.ID) == "" {
|
||||
return ErrMissingProject
|
||||
}
|
||||
if strings.TrimSpace(p.Title) == "" {
|
||||
return ErrMissingTitle
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if p.CreatedAt.IsZero() {
|
||||
p.CreatedAt = now
|
||||
}
|
||||
if p.UpdatedAt.IsZero() {
|
||||
p.UpdatedAt = now
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO project (id, title, desc, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
desc = excluded.desc,
|
||||
updated_at = excluded.updated_at`,
|
||||
p.ID, p.Title, p.Desc, formatTime(p.CreatedAt), formatTime(p.UpdatedAt))
|
||||
if err != nil {
|
||||
return fmt.Errorf("projectindex: upsert project: %w", err)
|
||||
}
|
||||
s.log.Debug("project upserted", "id", p.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProject returns the project with the given id. It returns ErrProjectNotFound
|
||||
// when the id is blank or does not exist.
|
||||
func (s *Store) GetProject(ctx context.Context, id string) (*Project, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, `SELECT id, title, desc, created_at, updated_at
|
||||
FROM project WHERE id = ?`, id)
|
||||
p, err := scanProject(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("projectindex: get project: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// DeleteProject removes the project by id and, as a cascade, every chapter that
|
||||
// references it. It returns ErrProjectNotFound when the id is blank or no
|
||||
// project row was removed. The project removal and the chapter removal happen in
|
||||
// one transaction so a failure never leaves an orphaned project or chapter.
|
||||
func (s *Store) DeleteProject(ctx context.Context, id string) error {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return ErrProjectNotFound
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("projectindex: begin delete tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
res, err := tx.ExecContext(ctx, `DELETE FROM project_chapter WHERE project_id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("projectindex: delete chapters: %w", err)
|
||||
}
|
||||
if _, err := res.RowsAffected(); err != nil {
|
||||
return fmt.Errorf("projectindex: delete chapters row count: %w", err)
|
||||
}
|
||||
|
||||
res, err = tx.ExecContext(ctx, `DELETE FROM project WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("projectindex: delete project: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("projectindex: delete row count: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrProjectNotFound
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("projectindex: commit delete tx: %w", err)
|
||||
}
|
||||
s.log.Debug("project deleted", "id", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanProject reads one project row.
|
||||
func scanProject(sc rowScanner) (*Project, error) {
|
||||
var (
|
||||
p Project
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
if err := sc.Scan(&p.ID, &p.Title, &p.Desc, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var err error
|
||||
if p.CreatedAt, err = parseTime(createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.UpdatedAt, err = parseTime(updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// newID returns a 128-bit cryptographically random id, hex-encoded. It avoids a
|
||||
// UUID dependency so the package stays self-contained. rand.Read fails only if
|
||||
// the platform entropy source is broken, so the error is propagated.
|
||||
func newID() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("projectindex: generate id: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// formatTime encodes t in UTC as an RFC3339Nano string so ordering and
|
||||
// round-trips are stable across processes.
|
||||
func formatTime(t time.Time) string {
|
||||
return t.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// parseTime decodes a stored RFC3339 string; blank input yields a zero time.
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("projectindex: parse time %q: %w", s, err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package projectindex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// testLogger returns a logger that writes nowhere, keeping test output clean.
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// newTestStore opens an isolated in-memory SQLite DB and builds a Store over it.
|
||||
// Each call owns a private DB, so parallel tests are isolated by construction.
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
d, err := db.Open(ctx, ":memory:", testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
|
||||
s, err := New(ctx, d, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// baseProject is a minimal valid project for tests.
|
||||
func baseProject() Project {
|
||||
return Project{ID: "proj.1", Title: "Cyrene", Desc: "陪伴型 AI 助手"}
|
||||
}
|
||||
|
||||
func TestNew_NilDB_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := New(context.Background(), nil, testLogger()); !errors.Is(err, ErrNilDB) {
|
||||
t.Fatalf("New(nil db) err = %v, want ErrNilDB", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_Schema_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
d, err := db.Open(ctx, ":memory:", testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
if _, err := New(ctx, d, testLogger()); err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
for _, tbl := range []string{"project", "project_chapter"} {
|
||||
var n int
|
||||
if err := d.QueryRowContext(ctx,
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", tbl).Scan(&n); err != nil {
|
||||
t.Fatalf("check table %s: %v", tbl, err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("table %q missing", tbl)
|
||||
}
|
||||
}
|
||||
var n int
|
||||
if err := d.QueryRowContext(ctx,
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='index' AND name='idx_project_chapter_project_id'").Scan(&n); err != nil {
|
||||
t.Fatalf("check index: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("index idx_project_chapter_project_id missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_PutGetProject_Upsert(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
oldTime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
if err := s.PutProject(ctx, Project{ID: "proj.1", Title: "T1", Desc: "D1", CreatedAt: oldTime}); err != nil {
|
||||
t.Fatalf("PutProject insert: %v", err)
|
||||
}
|
||||
// Upsert preserves the original created_at while updating title/desc.
|
||||
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)
|
||||
}
|
||||
got, err := s.GetProject(ctx, "proj.1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetProject: %v", err)
|
||||
}
|
||||
if got.Title != "T2" {
|
||||
t.Errorf("title = %q, want T2", got.Title)
|
||||
}
|
||||
if got.Desc != "D2" {
|
||||
t.Errorf("desc = %q, want D2", got.Desc)
|
||||
}
|
||||
if !got.CreatedAt.Equal(oldTime) {
|
||||
t.Errorf("created_at = %v, want preserved %v", got.CreatedAt, oldTime)
|
||||
}
|
||||
if got.UpdatedAt.IsZero() {
|
||||
t.Errorf("updated_at not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_PutProject_Validation_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
p Project
|
||||
wantErr error
|
||||
}{
|
||||
{name: "blank id", p: Project{Title: "T"}, wantErr: ErrMissingProject},
|
||||
{name: "blank title", p: Project{ID: "p"}, wantErr: ErrMissingTitle},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := s.PutProject(ctx, tt.p); !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("PutProject err = %v, want wrap of %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_GetProject_NotFound_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, id := range []string{"", "does-not-exist"} {
|
||||
if _, err := s.GetProject(ctx, id); !errors.Is(err, ErrProjectNotFound) {
|
||||
t.Fatalf("GetProject(%q) err = %v, want ErrProjectNotFound", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_AddListChapters_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||||
t.Fatalf("PutProject: %v", err)
|
||||
}
|
||||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "概述", Content: "第一行\n第二行"}); err != nil {
|
||||
t.Fatalf("AddChapter 1: %v", err)
|
||||
}
|
||||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "实现", Content: "另外一个章节"}); err != nil {
|
||||
t.Fatalf("AddChapter 2: %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListChapters(ctx, "proj.1")
|
||||
if err != nil {
|
||||
t.Fatalf("ListChapters: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("ListChapters = %d chapters, want 2", len(list))
|
||||
}
|
||||
first := list[0]
|
||||
if first.Title != "概述" || first.Content != "第一行\n第二行" {
|
||||
t.Errorf("first chapter = %+v, want 概述/第一行", first)
|
||||
}
|
||||
if first.ProjectID != "proj.1" {
|
||||
t.Errorf("ProjectID = %q, want proj.1", first.ProjectID)
|
||||
}
|
||||
if first.ID == "" {
|
||||
t.Errorf("AddChapter did not assign an id")
|
||||
}
|
||||
if first.CreatedAt.IsZero() || first.UpdatedAt.IsZero() {
|
||||
t.Errorf("AddChapter did not assign timestamps")
|
||||
}
|
||||
|
||||
// A different project must not see these chapters.
|
||||
other, err := s.ListChapters(ctx, "proj.other")
|
||||
if err != nil {
|
||||
t.Fatalf("ListChapters(proj.other): %v", err)
|
||||
}
|
||||
if len(other) != 0 {
|
||||
t.Fatalf("ListChapters(proj.other) = %d chapters, want 0", len(other))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_AddChapter_Validation_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
projectID string
|
||||
ch Chapter
|
||||
wantErr error
|
||||
}{
|
||||
{name: "blank project", ch: Chapter{Title: "T", Content: "C"}, wantErr: ErrMissingProject},
|
||||
{name: "blank title", projectID: "p", ch: Chapter{Content: "C"}, wantErr: ErrMissingTitle},
|
||||
{name: "blank content", projectID: "p", ch: Chapter{Title: "T"}, wantErr: ErrMissingContent},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := s.AddChapter(ctx, tt.projectID, tt.ch); !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("AddChapter err = %v, want wrap of %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_ListChapters_MissingProject_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestStore(t)
|
||||
if _, err := s.ListChapters(context.Background(), ""); !errors.Is(err, ErrMissingProject) {
|
||||
t.Fatalf("ListChapters(\"\") err = %v, want ErrMissingProject", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Search_HitProject(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.PutProject(ctx, Project{ID: "proj.1", Title: "Cyrene 重构", Desc: "陪伴型 AI 助手"}); err != nil {
|
||||
t.Fatalf("PutProject: %v", err)
|
||||
}
|
||||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "实现", Content: "纯文本内容"}); err != nil {
|
||||
t.Fatalf("AddChapter: %v", err)
|
||||
}
|
||||
|
||||
res, err := s.Search(ctx, "proj.1", "陪伴型")
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if res.Project == nil {
|
||||
t.Fatalf("Search result.Project nil, want project hit (desc layer)")
|
||||
}
|
||||
if res.Project.ID != "proj.1" || res.Project.Desc != "陪伴型 AI 助手" {
|
||||
t.Errorf("Project = %+v, want proj.1/陪伴型 AI 助手", res.Project)
|
||||
}
|
||||
if len(res.Chapters) != 0 {
|
||||
t.Fatalf("Search result.Chapters = %d, want 0 (chapter content did not match)", len(res.Chapters))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Search_HitChapter_分层(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||||
t.Fatalf("PutProject: %v", err)
|
||||
}
|
||||
content := "第一行\n核心算法在第二行\n第三行\n第四行\n第五行"
|
||||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "实现", Content: content}); err != nil {
|
||||
t.Fatalf("AddChapter: %v", err)
|
||||
}
|
||||
|
||||
res, err := s.Search(ctx, "proj.1", "算法")
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if res.Project != nil {
|
||||
t.Fatalf("Search result.Project = %v, want nil (project layer did not match)", res.Project)
|
||||
}
|
||||
if len(res.Chapters) != 1 {
|
||||
t.Fatalf("Search result.Chapters = %d, want 1", len(res.Chapters))
|
||||
}
|
||||
hit := res.Chapters[0]
|
||||
if hit.Chapter.Title != "实现" || hit.Chapter.Content != content {
|
||||
t.Errorf("Chapter = %+v, want 实现", hit.Chapter)
|
||||
}
|
||||
if len(hit.Matches) != 1 {
|
||||
t.Fatalf("Matches = %d, want 1", len(hit.Matches))
|
||||
}
|
||||
m := hit.Matches[0]
|
||||
if m.LineNumber != 2 {
|
||||
t.Errorf("LineNumber = %d, want 2", m.LineNumber)
|
||||
}
|
||||
if m.Text != "核心算法在第二行" {
|
||||
t.Errorf("Text = %q, want 核心算法在第二行", m.Text)
|
||||
}
|
||||
wantContext := "第一行\n核心算法在第二行\n第三行\n第四行"
|
||||
if m.Context != wantContext {
|
||||
t.Errorf("Context = %q, want %q", m.Context, wantContext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Search_NoHit(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||||
t.Fatalf("PutProject: %v", err)
|
||||
}
|
||||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "实现", Content: "纯文本内容"}); err != nil {
|
||||
t.Fatalf("AddChapter: %v", err)
|
||||
}
|
||||
|
||||
res, err := s.Search(ctx, "proj.1", "不存在的关键词")
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if res.Project != nil {
|
||||
t.Errorf("Project = %v, want nil", res.Project)
|
||||
}
|
||||
if len(res.Chapters) != 0 {
|
||||
t.Errorf("Chapters = %d, want 0", len(res.Chapters))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Search_ProjectNotFound_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestStore(t)
|
||||
if _, err := s.Search(context.Background(), "missing", "kw"); !errors.Is(err, ErrProjectNotFound) {
|
||||
t.Fatalf("Search(missing) err = %v, want ErrProjectNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Search_BlankKeyword_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||||
t.Fatalf("PutProject: %v", err)
|
||||
}
|
||||
if _, err := s.Search(ctx, "proj.1", " "); !errors.Is(err, ErrMissingKeyword) {
|
||||
t.Fatalf("Search(blank) err = %v, want ErrMissingKeyword", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_DeleteProject_Cascade(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||||
t.Fatalf("PutProject: %v", err)
|
||||
}
|
||||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "c1", Content: "x"}); err != nil {
|
||||
t.Fatalf("AddChapter c1: %v", err)
|
||||
}
|
||||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "c2", Content: "y"}); err != nil {
|
||||
t.Fatalf("AddChapter c2: %v", err)
|
||||
}
|
||||
|
||||
if err := s.DeleteProject(ctx, "proj.1"); err != nil {
|
||||
t.Fatalf("DeleteProject: %v", err)
|
||||
}
|
||||
if _, err := s.GetProject(ctx, "proj.1"); !errors.Is(err, ErrProjectNotFound) {
|
||||
t.Fatalf("GetProject after delete err = %v, want ErrProjectNotFound", err)
|
||||
}
|
||||
ch, err := s.ListChapters(ctx, "proj.1")
|
||||
if err != nil {
|
||||
t.Fatalf("ListChapters after delete: %v", err)
|
||||
}
|
||||
if len(ch) != 0 {
|
||||
t.Fatalf("chapters not cascaded, got %d", len(ch))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_DeleteProject_NotFound_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, id := range []string{"", "missing"} {
|
||||
if err := s.DeleteProject(ctx, id); !errors.Is(err, ErrProjectNotFound) {
|
||||
t.Fatalf("DeleteProject(%q) err = %v, want ErrProjectNotFound", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Search_SQLInjectionSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
maliciousID := "proj.1; DROP TABLE project; --"
|
||||
if err := s.PutProject(ctx, Project{ID: maliciousID, Title: "恶意', `x`", Desc: "描述"}); err != nil {
|
||||
t.Fatalf("PutProject malicious: %v", err)
|
||||
}
|
||||
if err := s.AddChapter(ctx, maliciousID, Chapter{Title: "c'--", Content: "含有 keyword 的行\n普通行"}); err != nil {
|
||||
t.Fatalf("AddChapter malicious: %v", err)
|
||||
}
|
||||
|
||||
// A keyword full of SQL metacharacters must not alter behavior or data.
|
||||
res, err := s.Search(ctx, maliciousID, "' OR 1=1 --")
|
||||
if err != nil {
|
||||
t.Fatalf("Search malicious keyword: %v", err)
|
||||
}
|
||||
if res.Project != nil {
|
||||
t.Errorf("Project = %v, want nil for non-matching keyword", res.Project)
|
||||
}
|
||||
if len(res.Chapters) != 0 {
|
||||
t.Errorf("Chapters = %d, want 0 for non-matching keyword", len(res.Chapters))
|
||||
}
|
||||
if _, err := s.Search(ctx, maliciousID, "zzz_never"); err != nil {
|
||||
t.Fatalf("Search benign keyword on malicious id: %v", err)
|
||||
}
|
||||
|
||||
// The malicious id must round-trip intact (proves parameterized lookup).
|
||||
got, err := s.GetProject(ctx, maliciousID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProject malicious: %v", err)
|
||||
}
|
||||
if got.ID != maliciousID {
|
||||
t.Errorf("ID = %q, want %q", got.ID, maliciousID)
|
||||
}
|
||||
// And the malicious project must still be queryable, not dropped.
|
||||
if _, err := s.GetProject(ctx, "project"); !errors.Is(err, ErrProjectNotFound) {
|
||||
t.Fatalf("project table intact check: err = %v, want ErrProjectNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Concurrent_AddChapter_并发(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||||
t.Fatalf("PutProject: %v", err)
|
||||
}
|
||||
|
||||
const n = 30
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
errs <- s.AddChapter(ctx, "proj.1", Chapter{Title: fmt.Sprintf("ch%d", i), Content: "并发内容"})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent AddChapter error: %v", err)
|
||||
}
|
||||
}
|
||||
ch, err := s.ListChapters(ctx, "proj.1")
|
||||
if err != nil {
|
||||
t.Fatalf("ListChapters: %v", err)
|
||||
}
|
||||
if len(ch) != n {
|
||||
t.Fatalf("ListChapters = %d chapters, want %d", len(ch), n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_Search_MatchInMultipleLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.PutProject(ctx, baseProject()); err != nil {
|
||||
t.Fatalf("PutProject: %v", err)
|
||||
}
|
||||
content := "关于 AI\nAI 是一个话题\n无关行\n结尾 AI 又出现"
|
||||
if err := s.AddChapter(ctx, "proj.1", Chapter{Title: "概述", Content: content}); err != nil {
|
||||
t.Fatalf("AddChapter: %v", err)
|
||||
}
|
||||
|
||||
res, err := s.Search(ctx, "proj.1", "ai")
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(res.Chapters) != 1 {
|
||||
t.Fatalf("Chapters = %d, want 1", len(res.Chapters))
|
||||
}
|
||||
hit := res.Chapters[0]
|
||||
if len(hit.Matches) != 3 {
|
||||
t.Fatalf("Matches = %d, want 3", len(hit.Matches))
|
||||
}
|
||||
if hit.Matches[0].LineNumber != 1 || hit.Matches[1].LineNumber != 2 || hit.Matches[2].LineNumber != 4 {
|
||||
t.Errorf("line numbers = [%d %d %d], want [1 2 4]",
|
||||
hit.Matches[0].LineNumber, hit.Matches[1].LineNumber, hit.Matches[2].LineNumber)
|
||||
}
|
||||
// The keyword was lowercase "ai" but the content holds uppercase "AI",
|
||||
// so the match is case-insensitive while Text preserves the original casing.
|
||||
if !strings.Contains(hit.Matches[0].Text, "AI") {
|
||||
t.Errorf("expected case-insensitive match, Text = %q", hit.Matches[0].Text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package projectindex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// contextRadius is the number of lines shown on each side of a matched line in
|
||||
// LineMatch.Context.
|
||||
const contextRadius = 2
|
||||
|
||||
// LineMatch is a single matched line inside a chapter's content plus its
|
||||
// surrounding context. LineNumber is 1-based within the chapter's Content.
|
||||
type LineMatch struct {
|
||||
LineNumber int
|
||||
Text string
|
||||
Context string
|
||||
}
|
||||
|
||||
// ChapterHit is a chapter whose content matched a keyword, with the located
|
||||
// lines (and their context) that matched.
|
||||
type ChapterHit struct {
|
||||
Chapter *Chapter
|
||||
Matches []LineMatch
|
||||
}
|
||||
|
||||
// SearchResult is the hierarchical result of a Search. Project carries the
|
||||
// matched project index entry + description (layer 1/2), set only when the
|
||||
// keyword hits the project title or desc; Chapters carries the matched chapters
|
||||
// (layer 3) with located lines. When nothing matches, Project is nil and
|
||||
// Chapters is empty, with no error.
|
||||
type SearchResult struct {
|
||||
Project *Project
|
||||
Chapters []ChapterHit
|
||||
}
|
||||
|
||||
// Search looks up keyword within a single project, walking the hierarchy
|
||||
// 项目索引 → 项目描述 → 章节内容. It first loads the project (ErrProjectNotFound
|
||||
// when it does not exist), sets SearchResult.Project when the keyword hits the
|
||||
// project title or description, then scans every chapter for content hits,
|
||||
// producing located lines plus their context. A blank keyword returns
|
||||
// ErrMissingKeyword; a keyword that hits nothing returns a non-nil SearchResult
|
||||
// with Project and Chapters unset.
|
||||
func (s *Store) Search(ctx context.Context, projectID, keyword string) (*SearchResult, error) {
|
||||
kw := strings.ToLower(strings.TrimSpace(keyword))
|
||||
if kw == "" {
|
||||
return nil, ErrMissingKeyword
|
||||
}
|
||||
proj, err := s.GetProject(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &SearchResult{}
|
||||
if containsFold(proj.Title, kw) || containsFold(proj.Desc, kw) {
|
||||
result.Project = proj
|
||||
}
|
||||
chapters, err := s.ListChapters(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range chapters {
|
||||
matches := findLineMatches(chapters[i].Content, kw)
|
||||
if len(matches) == 0 {
|
||||
continue
|
||||
}
|
||||
ch := chapters[i]
|
||||
result.Chapters = append(result.Chapters, ChapterHit{Chapter: &ch, Matches: matches})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// containsFold reports whether s (case-insensitively) contains sub, which must
|
||||
// already be lowercased by the caller.
|
||||
func containsFold(s, sub string) bool {
|
||||
return strings.Contains(strings.ToLower(s), sub)
|
||||
}
|
||||
|
||||
// findLineMatches returns one LineMatch per content line that (case-insensitively)
|
||||
// contains keyword. Context is the surrounding contextRadius lines on each side,
|
||||
// joined by newlines and including the matched line.
|
||||
func findLineMatches(content, keyword string) []LineMatch {
|
||||
lines := strings.Split(content, "\n")
|
||||
matches := make([]LineMatch, 0)
|
||||
for i, line := range lines {
|
||||
if strings.Contains(strings.ToLower(line), keyword) {
|
||||
matches = append(matches, LineMatch{
|
||||
LineNumber: i + 1,
|
||||
Text: line,
|
||||
Context: buildContext(lines, i),
|
||||
})
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
// buildContext returns lines[start..end] around idx (inclusive) joined by \n,
|
||||
// clamped to the slice bounds so the context never overflows.
|
||||
func buildContext(lines []string, idx int) string {
|
||||
start := idx - contextRadius
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := idx + contextRadius
|
||||
if end >= len(lines) {
|
||||
end = len(lines) - 1
|
||||
}
|
||||
return strings.Join(lines[start:end+1], "\n")
|
||||
}
|
||||
@@ -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