Files
hyperguild/ingestion/internal/mcp/tools_answer.go
Mathias 75685e7b67
All checks were successful
CI / Lint / Test / Vet (push) Successful in 11s
CI / Mirror to GitHub (push) Successful in 4s
feat(brain): structured wing/hall taxonomy + obsidian-compatible layout
Adds a two-dimensional address (wing, hall) to brain notes. A wing is a
topic domain (e.g. jepa-fx, hyperguild); a hall is one of a closed
vocabulary of memory types (facts, decisions, failures, hypotheses,
sources). Notes route to brain/wiki/<wing>/<hall>/<slug>.md with
wing/hall/created_at YAML frontmatter, making the directory a valid
Obsidian vault.

Changes:
- new package ingestion/internal/brain (NotePath, ValidHalls, Sanitise,
  BuildWingIndex, BuildAllWingIndexes)
- api.WriteNote refactored to WriteNoteOptions; wing+hall routes to
  brain/wiki/, otherwise falls back to brain/knowledge/ (legacy)
- search.Query → QueryOptions with optional Wing/Hall filtering; Result
  carries wing/hall extracted from frontmatter or path segments
- MCP tools brain_write and brain_query gain optional wing/hall params
  (hall enum-validated); new brain_index tool regenerates _index.md MOC
- POST /index REST endpoint mirrors brain_index
- brain_write auto-rebuilds the wing's _index.md after a wing+hall write
- scripts/migrate-brain-halls.sh migrates flat brain/wiki/{concepts,entities}/
  into the new layout (dry-run by default, --commit applies)

All existing tests pass; new tests cover wing/hall write routing, scope
filtering, invalid hall rejection, _index.md generation, and migration
script paths.

Closes hyperguild#1.
2026-05-18 20:47:08 +02:00

115 lines
3.0 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
)
const (
answerSystemPrompt = `You are a knowledge assistant. Answer the question using ONLY the provided sources.
Cite source file paths inline when referencing specific content.
If the context does not contain enough information to answer, say so clearly.`
classifySystemPrompt = `Classify the document. Respond with JSON only, no markdown fences.
{"type":"...","title":"...","tags":["..."]}
Valid types: spec, plan, decision, note, wiki, log, code, unknown.`
)
type brainAnswerArgs struct {
Query string `json:"query"`
}
func (s *Server) brainAnswer(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
if s.answerLLM == nil {
return nil, fmt.Errorf("answer LLM not configured: set BRAIN_LLM_PRIMARY_URL")
}
var a brainAnswerArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if a.Query == "" {
return nil, fmt.Errorf("query is required")
}
results, err := search.Query(s.brainDir, search.QueryOptions{Query: a.Query, Limit: 10})
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
if len(results) == 0 {
return json.Marshal(map[string]any{
"answer": "No relevant content found in brain.",
"sources": []string{},
})
}
var sb strings.Builder
sources := make([]string, 0, len(results))
for _, r := range results {
fmt.Fprintf(&sb, "<source path=%q>\n%s\n</source>\n\n", r.Path, r.Excerpt)
sources = append(sources, r.Path)
}
answer, err := s.answerLLM(ctx, answerSystemPrompt, sb.String()+"Question: "+a.Query)
if err != nil {
return nil, fmt.Errorf("llm: %w", err)
}
return json.Marshal(map[string]any{
"answer": answer,
"sources": sources,
})
}
type brainClassifyArgs struct {
Text string `json:"text"`
}
type classifyResult struct {
Type string `json:"type"`
Title string `json:"title"`
Tags []string `json:"tags"`
}
func (s *Server) brainClassify(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
if s.answerLLM == nil {
return nil, fmt.Errorf("answer LLM not configured: set BRAIN_LLM_PRIMARY_URL")
}
var a brainClassifyArgs
if err := json.Unmarshal(args, &a); err != nil {
return nil, fmt.Errorf("parse args: %w", err)
}
if a.Text == "" {
return nil, fmt.Errorf("text is required")
}
text := a.Text
if len(text) > 3000 {
text = text[:3000]
}
raw, err := s.answerLLM(ctx, classifySystemPrompt, text)
if err != nil {
return nil, fmt.Errorf("llm: %w", err)
}
// Strip markdown fences if model adds them despite the instruction.
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var cr classifyResult
if err := json.Unmarshal([]byte(raw), &cr); err != nil {
return nil, fmt.Errorf("parse classify response %q: %w", raw, err)
}
if cr.Tags == nil {
cr.Tags = []string{}
}
return json.Marshal(cr)
}