Files
Mathias f43e0bccbf
All checks were successful
CI / Lint / Test / Vet (push) Successful in 13s
CI / Mirror to GitHub (push) Successful in 4s
feat(graph): wire graphsync into MCP write/ingest/tunnel handlers
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>
2026-05-23 15:21:33 +02:00

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
}