feat(graph): GraphRAG augment brain_answer with top-hit subgraph
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 <related> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,29 @@ func (s *Server) brainAnswer(ctx context.Context, args json.RawMessage) (json.Ra
|
|||||||
sources = append(sources, r.Path)
|
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("<related>\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("</related>\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
answer, err := s.answerLLM(ctx, answerSystemPrompt, sb.String()+"Question: "+a.Query)
|
answer, err := s.answerLLM(ctx, answerSystemPrompt, sb.String()+"Question: "+a.Query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("llm: %w", err)
|
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 {
|
type brainClassifyArgs struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user