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>
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/session"
|
||||
@@ -194,9 +195,23 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw
|
||||
slog.Warn("brain_write: auto-tunnel failed", "src", relPath, "err", err)
|
||||
}
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_write", relPath)
|
||||
return json.Marshal(map[string]string{"path": relPath})
|
||||
}
|
||||
|
||||
// indexInGraph is a best-effort wrapper around graphsync.IndexDoc that
|
||||
// logs failures but never propagates them — the underlying write/ingest
|
||||
// has already succeeded and the graph is an augmentation, not a
|
||||
// correctness invariant.
|
||||
func (s *Server) indexInGraph(ctx context.Context, op, relPath string) {
|
||||
if s.graph == nil || relPath == "" {
|
||||
return
|
||||
}
|
||||
if err := graphsync.IndexDoc(ctx, s.graph, s.brainDir, relPath); err != nil {
|
||||
slog.Warn(op+": graph index failed", "path", relPath, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
type brainTunnelArgs struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
@@ -213,6 +228,8 @@ func (s *Server) brainTunnel(ctx context.Context, args json.RawMessage) (json.Ra
|
||||
if err := brain.WriteTunnel(s.brainDir, a.Source, a.Target); err != nil {
|
||||
return nil, fmt.Errorf("tunnel: %w", err)
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_tunnel", a.Source)
|
||||
s.indexInGraph(ctx, "brain_tunnel", a.Target)
|
||||
return json.Marshal(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
@@ -268,6 +285,11 @@ func (s *Server) brainIngestRaw(ctx context.Context, args json.RawMessage) (json
|
||||
if warnings == nil {
|
||||
warnings = []string{}
|
||||
}
|
||||
if !a.DryRun {
|
||||
for _, p := range pages {
|
||||
s.indexInGraph(ctx, "brain_ingest_raw", p)
|
||||
}
|
||||
}
|
||||
return json.Marshal(map[string]any{"pages": pages, "warnings": warnings})
|
||||
}
|
||||
|
||||
@@ -358,6 +380,11 @@ func (s *Server) runIngest(ctx context.Context, content, source string, dryRun b
|
||||
if pages == nil {
|
||||
pages = []string{}
|
||||
}
|
||||
if !dryRun {
|
||||
for _, p := range pages {
|
||||
s.indexInGraph(ctx, "brain_ingest", p)
|
||||
}
|
||||
}
|
||||
warnings := result.Warnings
|
||||
if warnings == nil {
|
||||
warnings = []string{}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphstore"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||
@@ -42,6 +44,7 @@ type Server struct {
|
||||
reranker *reranker.Client // nil = no rerank, BM25 top-10 → LLM
|
||||
vector search.VectorSearcher // nil = BM25-only retrieval
|
||||
embedder search.Embedder // nil = BM25-only retrieval
|
||||
graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled
|
||||
}
|
||||
|
||||
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
||||
@@ -73,6 +76,19 @@ func (s *Server) WithHybridRetrieval(v search.VectorSearcher, e search.Embedder)
|
||||
return s
|
||||
}
|
||||
|
||||
// WithGraph wires the brain entities + edges store so every successful
|
||||
// brain_write / brain_ingest / brain_tunnel re-indexes its written docs
|
||||
// into the graph, and so brain_graph + GraphRAG-augmented brain_answer
|
||||
// are available. nil disables graph features and is the legacy default.
|
||||
func (s *Server) WithGraph(g *graphstore.PGStore) *Server {
|
||||
if g == nil {
|
||||
s.graph = nil
|
||||
return s
|
||||
}
|
||||
s.graph = g
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
||||
if r.Method == http.MethodGet {
|
||||
|
||||
Reference in New Issue
Block a user