Files
Cyrene_v2/internal/runtime/memory/projectindex/store.go
T
Aska dbcf3df8a3
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: RAG 知识库真实数据 + 渠道端到端接线(并行∦wire.go)+ 主会话收口
【渠道端到端·接线(agent-72)】
- 新建 internal/services/channelbridge/:桥接服务(Start 对配置渠道 Connect(10s超时/warn)+SetMessageHandler+ReadMessages;
  入站 UnifiedMessage→sessions.GetOrCreate(ResolveActor 白名单结构性识别)→loop.Run→SendMessage 回执,chat 文本)。
- cmd/cyrene/channels.go:从 CHANNEL_* env 读 4 渠道凭据(telegram/qq_obv11/weixin_ilink/webhook)。
- wire.go:app 加 channelBridge 字段,wireChannels 构造适配器+bridge.Start(),close() 收尾。
- 无真凭据降级:无 CHANNEL_* → configured()false 跳过不 dial;Connect 失败 Warn 不阻启动;
  webhook 仅入站(回执 ErrUnsupported 记 debug)。身份识别绝不靠提示词。收发端到端待凭据验证。

【RAG 知识库(agent-71)】
- internal/runtime/knowledge/:真实数据源(doc=资料/记忆=经历 分离 design/03 §3③)。
  ListDocuments 走 projectindex.ListProjects(+ListChapters)、Search 走 projectindex.Search(标题/描述+章节
  命中行+上下文)+retriever.Retrieve(配 vector 真语义,命中标 semantic:true)。
- gateway/server.go:RagStore 接口+Deps.RAG 可空+3 路由(GET /admin/rag、POST /admin/rag/query、/delete)。
- admin.go:adminListRag/adminSearchRag(空 query 400)/adminDeleteRag(写 501 not_implemented,M10)。
- projectindex/store.go:ListProjects/ListChapters。
- 前端 Rag.tsx:docs 从 GET /admin/rag 拉、检索走 POST /admin/rag/query、删除调真端点(501 显示 Notice),
  上传/分块/嵌入显示 M10 Notice;api.ts/types.ts 加 adminListRag/adminSearchRag/adminDeleteRag。

【主会话收口 wire.go】projStore 提到 app struct+wireMemory 赋值;knowledge.New(...) 注入 gateway.Deps.RAG。

实机验证:admin/rag 200(documents[] 空表非壳)、channels 枚举 4 渠道、health ok。
go build/vet/test 全绿 + 前端 tsc。wire.go 567 行。

遗留(记录):① 群聊/陌生人 MemWritable 记忆隔离未接 loop(AGENTS.md 红线,待修) ② webhook InboundHandler
需 gateway 挂载 ③ RAG 语义路径 persona-scoped(project 记忆其他 persona 不浮现)。
2026-09-06 20:09:09 +08:00

159 lines
5.9 KiB
Go

// 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))
}
// ListProjects returns every project index entry in insertion order (created_at
// ASC, id ASC). An empty (non-nil) slice is returned when there are none, so
// callers can range over it unconditionally. A read error is wrapped with %w so
// callers can use errors.Is against the package sentinels.
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, id 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, 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)`