diff --git a/internal/runtime/memory/projectindex/chapter.go b/internal/runtime/memory/projectindex/chapter.go new file mode 100644 index 0000000..f5c49d9 --- /dev/null +++ b/internal/runtime/memory/projectindex/chapter.go @@ -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 +} diff --git a/internal/runtime/memory/projectindex/project.go b/internal/runtime/memory/projectindex/project.go new file mode 100644 index 0000000..0126df6 --- /dev/null +++ b/internal/runtime/memory/projectindex/project.go @@ -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 +} diff --git a/internal/runtime/memory/projectindex/projectindex_test.go b/internal/runtime/memory/projectindex/projectindex_test.go new file mode 100644 index 0000000..4f997f2 --- /dev/null +++ b/internal/runtime/memory/projectindex/projectindex_test.go @@ -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) + } +} diff --git a/internal/runtime/memory/projectindex/search.go b/internal/runtime/memory/projectindex/search.go new file mode 100644 index 0000000..8bcdd29 --- /dev/null +++ b/internal/runtime/memory/projectindex/search.go @@ -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") +} diff --git a/internal/runtime/memory/projectindex/store.go b/internal/runtime/memory/projectindex/store.go new file mode 100644 index 0000000..46ab836 --- /dev/null +++ b/internal/runtime/memory/projectindex/store.go @@ -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)`