Commit 2 of Track A. Service stays a no-op until BRAIN_GRAPH_ENABLED= true; flipping it on creates the schema (idempotent), starts indexing every successful write, and optionally backfills the existing brain dir. - internal/graphsync: best-effort wrapper around graph.Extract + graphstore. IndexDoc reads docPath under brainDir, parses, upserts entity + replaces edges. BackfillFromBrainDir walks wiki/ + knowledge/. Both are no-ops on nil store so callers wire unconditionally. - mcp.Server gains WithGraph builder + graphsync.Store field. brain_write, brain_ingest, brain_ingest_raw, brain_tunnel call indexInGraph after success — failures slog.Warn but never propagate (graph is augmentation, not correctness). - cmd/server gates the wiring on BRAIN_GRAPH_ENABLED=true (default off so first rollout doesn't surprise). BRAIN_GRAPH_BACKFILL=true triggers a one-shot walk of the brain dir on boot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
113 lines
3.6 KiB
Go
113 lines
3.6 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"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"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
|
|
}
|
|
abs := filepath.Join(brainDir, filepath.FromSlash(relPath))
|
|
content, err := os.ReadFile(abs)
|
|
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
|
|
}
|
|
roots := []string{"wiki", "knowledge"}
|
|
for _, root := range roots {
|
|
base := filepath.Join(brainDir, root)
|
|
if _, err := os.Stat(base); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
err := filepath.WalkDir(base, func(path string, d os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if filepath.Ext(path) != ".md" {
|
|
return nil
|
|
}
|
|
rel, relErr := filepath.Rel(brainDir, path)
|
|
if relErr != nil {
|
|
return fmt.Errorf("rel %q: %w", path, relErr)
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if err := IndexDoc(ctx, store, brainDir, rel); err != nil {
|
|
return fmt.Errorf("index %q: %w", rel, err)
|
|
}
|
|
indexed++
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return indexed, fmt.Errorf("walk %s: %w", root, err)
|
|
}
|
|
}
|
|
return indexed, nil
|
|
}
|