graphsync.IndexDoc/BackfillFromBrainDir and wiki.LoadInventory/ RebuildIndex read notes by path (os.ReadFile, os.Open, os.ReadDir + open). A symlinked note put the target's title, aliases and wikilinks into the knowledge graph, the wiki inventory (and the LLM prompts built from it) and wiki/index.md; a FIFO hung each read. - graphsync: IndexDoc and the backfill read through brainfs; the backfill walks with WalkRegular, skips a missing root and aborts on any other walk error, including a symlinked root. - wiki: LoadInventory lists type dirs with the new brainfs.ReadDirRegular (every component a real dir, regular files only) and reads frontmatter through the root; a symlinked type dir is an error. RebuildIndex reads first sentences through the root. - brainfstest's outside file now carries the sentinel in frontmatter title and aliases, a heading and a wikilink, so extractors that ignore the body are tested too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
4.1 KiB
Go
127 lines
4.1 KiB
Go
// Package graphsync glues the disk-resident brain markdown documents to
|
|
// the relational graph in [graphstore]. It is a tiny seam so that the
|
|
// MCP handlers can call one function after every successful write or
|
|
// ingest without having to know either the parser or the postgres
|
|
// schema.
|
|
//
|
|
// Every operation is best-effort from the caller's perspective: if the
|
|
// graph store is unconfigured or the doc parses to nothing usable, the
|
|
// helpers return nil. Real database errors are surfaced so the caller
|
|
// can log them.
|
|
package graphsync
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"path"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graph"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
|
)
|
|
|
|
// Store is the subset of graphstore.PGStore that graphsync requires.
|
|
// Tests can substitute a fake by satisfying this interface.
|
|
type Store interface {
|
|
UpsertEntity(ctx context.Context, e graph.Entity) error
|
|
ReplaceEdgesForDoc(ctx context.Context, docPath string, edges []graph.Edge) error
|
|
DeleteByDoc(ctx context.Context, docPath string) error
|
|
}
|
|
|
|
// Compile-time assertion that *graphstore.PGStore satisfies Store.
|
|
var _ Store = (*graphstore.PGStore)(nil)
|
|
|
|
// IndexDoc reads docPath under brainDir and pushes one Entity + its
|
|
// outgoing wikilink Edges into store. relPath must be the
|
|
// forward-slash path relative to brainDir (the same shape returned by
|
|
// api.WriteNote).
|
|
//
|
|
// nil store is a valid no-op so callers can wire the helper
|
|
// unconditionally and let configuration decide whether the graph is
|
|
// populated.
|
|
func IndexDoc(ctx context.Context, store Store, brainDir, relPath string) error {
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
if relPath == "" {
|
|
return nil
|
|
}
|
|
bfs, err := brainfs.Open(brainDir)
|
|
if err != nil {
|
|
return fmt.Errorf("open brain: %w", err)
|
|
}
|
|
defer func() { _ = bfs.Close() }()
|
|
return indexDoc(ctx, store, bfs, relPath)
|
|
}
|
|
|
|
// indexDoc reads relPath through the brain root, regular files only
|
|
// (hyperguild#157): the brain is a Syncthing folder, and os.ReadFile
|
|
// followed a synced symlink into the graph and blocked on a FIFO.
|
|
func indexDoc(ctx context.Context, store Store, bfs *brainfs.Brain, relPath string) error {
|
|
content, err := bfs.ReadRegular(relPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read %q: %w", relPath, err)
|
|
}
|
|
ent, edges, ok := graph.Extract(relPath, content)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if err := store.UpsertEntity(ctx, ent); err != nil {
|
|
return fmt.Errorf("upsert entity: %w", err)
|
|
}
|
|
if err := store.ReplaceEdgesForDoc(ctx, relPath, edges); err != nil {
|
|
return fmt.Errorf("replace edges: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BackfillFromBrainDir walks every markdown file under brainDir/wiki/
|
|
// and brainDir/knowledge/, parses each, and upserts the resulting
|
|
// Entity + Edges. Existing rows are overwritten; orphan rows for
|
|
// already-deleted files are NOT cleaned up — call this only on a
|
|
// fresh store, or follow with a separate prune pass.
|
|
//
|
|
// Intended for one-shot startup runs against a populated brain dir.
|
|
// Cost scales linearly with corpus size; ~30 wiki pages plus the
|
|
// knowledge corpus is a few hundred ms.
|
|
func BackfillFromBrainDir(ctx context.Context, store Store, brainDir string) (indexed int, _ error) {
|
|
if store == nil {
|
|
return 0, nil
|
|
}
|
|
bfs, err := brainfs.Open(brainDir)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("open brain: %w", err)
|
|
}
|
|
defer func() { _ = bfs.Close() }()
|
|
for _, root := range []string{"wiki", "knowledge"} {
|
|
if _, err := bfs.Lstat(root); errors.Is(err, fs.ErrNotExist) {
|
|
continue
|
|
}
|
|
// Any walk error, including a root that is a symlink, aborts.
|
|
var walkErr error
|
|
err := bfs.WalkRegular(root, func(rel string) error {
|
|
if path.Ext(rel) != ".md" {
|
|
return nil
|
|
}
|
|
if err := indexDoc(ctx, store, bfs, rel); err != nil {
|
|
return fmt.Errorf("index %q: %w", rel, err)
|
|
}
|
|
indexed++
|
|
return nil
|
|
}, func(rel string, err error) {
|
|
if walkErr == nil {
|
|
walkErr = fmt.Errorf("%s: %w", rel, err)
|
|
}
|
|
})
|
|
if err == nil {
|
|
err = walkErr
|
|
}
|
|
if err != nil {
|
|
return indexed, fmt.Errorf("walk %s: %w", root, err)
|
|
}
|
|
}
|
|
return indexed, nil
|
|
}
|