Files
hyperguild/ingestion/internal/mcp/tools_answer.go
Mathias a56a4db963
All checks were successful
CI / Lint / Test / Vet (push) Successful in 10s
CI / Mirror to GitHub (push) Successful in 3s
feat(brain_answer): Qwen3-Reranker cross-encoder filter (opt-in)
Adds an opt-in cross-encoder rerank step between BM25 retrieval and LLM
synthesis. With BRAIN_RERANKER_URL set, brain_answer retrieves BM25
top-20, scores each excerpt against the query via Qwen3-Reranker on
Ollama, drops the "no" answers, and forwards up to 5 surviving sources
to the LLM. Unset, behaviour is unchanged (BM25 top-10 → LLM).

The reranker is a *filter*, not a re-ranker: Qwen3-Reranker emits a
binary yes/no token under its native chat template, and ties within the
"yes" set are broken by BM25 rank — what got retrieved first stays
ahead.

New package ingestion/internal/reranker:
- Client with URL, Model, HTTP fields.
- New(url, model) returns nil on empty url so callers can treat
  "feature disabled" as a single nil check.
- Score(ctx, query, docs) issues one /api/generate call per doc using
  the Qwen3-Reranker yes/no chat template (verbatim, because the model
  was trained on this exact wording). Parses the first non-think token.

Wiring:
- mcp.Server gains a WithReranker fluent setter to keep NewServer
  signature stable.
- brain_answer's BM25 limit jumps to 20 only when a reranker is wired,
  to give the filter something to do.
- cmd/server/main.go reads BRAIN_RERANKER_URL (+ optional
  BRAIN_RERANKER_MODEL, default dengcao/Qwen3-Reranker-0.6B:F16).

Tests cover: nil-on-empty-url, ordered yes/no scoring, request shape
(model, prompt contents, yes/no template), ambiguous response → 0,
empty doc slice, upstream-error propagation, plus an end-to-end
brain_answer integration that proves only the relevant note reaches the
LLM when noise.md is rejected.

Closes hyperguild#7.
2026-05-18 22:55:46 +02:00

153 lines
4.2 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
)
// rerankResults scores each candidate's excerpt against the query and
// returns up to top results whose score is positive, preserving the
// caller's input order (BM25 rank) within the kept set. The reranker is
// a filter: ties are broken by BM25, not by the reranker's binary score.
func rerankResults(ctx context.Context, rr *reranker.Client, query string, results []search.Result, top int) ([]search.Result, error) {
docs := make([]string, len(results))
for i, r := range results {
docs[i] = r.Excerpt
}
scores, err := rr.Score(ctx, query, docs)
if err != nil {
return nil, err
}
kept := make([]search.Result, 0, top)
for i, r := range results {
if scores[i] > 0 {
kept = append(kept, r)
}
if len(kept) == top {
break
}
}
return kept, nil
}
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")
}
// With reranker disabled: BM25 top-10 straight to the LLM.
// With reranker enabled: BM25 top-20 → cross-encoder filter → top-5.
bm25Limit := 10
if s.reranker != nil {
bm25Limit = 20
}
results, err := search.Query(s.brainDir, search.QueryOptions{Query: a.Query, Limit: bm25Limit})
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
if s.reranker != nil && len(results) > 0 {
results, err = rerankResults(ctx, s.reranker, a.Query, results, 5)
if err != nil {
return nil, fmt.Errorf("rerank: %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)
}