From 153ef6ccac97206a2b880a54dffca7ba832cfa2e Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 23 May 2026 15:24:45 +0200 Subject: [PATCH] feat(graph): GraphRAG augment brain_answer with top-hit subgraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 4 of Track A — the no-shelfware close-out the grill demanded. brain_answer now folds the 1-hop outgoing neighbourhood of its top BM25/rerank hit into the LLM's context as a block when BRAIN_GRAPH_ENABLED is on. With the flag off the prompt is byte-for- byte identical to the pre-Track-A behaviour, so existing tests still pass without modification. The hop list contains slug, edge_type, doc_path — no extra retrieval pass, no second LLM call, no file reads. The model can ignore the block when irrelevant; when it adds signal we get GraphRAG for free. Refs: docs/superpowers/specs/2026-05-homelab-training-graph-next-step.md in infra repo + grill addendum item "Track A: GraphRAG wiring into brain_answer is mandatory in same commit chain (no shelfware risk)". Co-Authored-By: Claude Opus 4.7 (1M context) --- ingestion/internal/mcp/tools_answer.go | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/ingestion/internal/mcp/tools_answer.go b/ingestion/internal/mcp/tools_answer.go index e8d15cc..9a72084 100644 --- a/ingestion/internal/mcp/tools_answer.go +++ b/ingestion/internal/mcp/tools_answer.go @@ -96,6 +96,29 @@ func (s *Server) brainAnswer(ctx context.Context, args json.RawMessage) (json.Ra sources = append(sources, r.Path) } + // GraphRAG augmentation: when the graph is wired, attach the 1-hop + // outgoing neighbourhood of the top BM25/rerank hit as an extra + // context block. The LLM can ignore it when irrelevant; when the + // neighbour adds signal we don't need a second retrieval pass. + // Failures are silently skipped — graph is augmentation, not + // correctness. + if reader, ok := s.graph.(graphReader); ok && len(results) > 0 { + topSlug := slugFromPath(results[0].Path) + if topSlug != "" { + if ns, gerr := reader.Subgraph(ctx, topSlug, 1); gerr == nil && len(ns) > 0 { + sb.WriteString("\n") + for _, n := range ns { + label := n.Title + if label == "" { + label = n.Slug + } + fmt.Fprintf(&sb, "- %s (%s) at %s\n", label, n.EdgeType, n.DocPath) + } + sb.WriteString("\n\n") + } + } + } + answer, err := s.answerLLM(ctx, answerSystemPrompt, sb.String()+"Question: "+a.Query) if err != nil { return nil, fmt.Errorf("llm: %w", err) @@ -107,6 +130,25 @@ func (s *Server) brainAnswer(ctx context.Context, args json.RawMessage) (json.Ra }) } +// slugFromPath converts "wiki/concepts/foo.md" → "foo". +// Returns "" when path has no .md suffix or empty basename. +func slugFromPath(path string) string { + if path == "" { + return "" + } + // strip directory + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' { + path = path[i+1:] + break + } + } + if !strings.HasSuffix(path, ".md") { + return "" + } + return strings.TrimSuffix(path, ".md") +} + type brainClassifyArgs struct { Text string `json:"text"` }