feat: escalate brain search to web when facts cannot confirm
Tests / Test (push) Skipped
Tests / Release (semver) (push) Skipped

This commit is contained in:
2026-08-13 20:23:07 +01:00
parent 368a757612
commit 515088f4e1
15 changed files with 440 additions and 16 deletions
+7 -2
View File
@@ -7,6 +7,8 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/eSlider/2dph/internal/brain/rank"
)
// Ready opens the Ladybug file for the life of the serve process.
@@ -17,7 +19,7 @@ func Ready() error {
// HTTP is the in-process API used by bin/brain/serve.go.
type HTTP struct{}
func (HTTP) Search(_ context.Context, query string, limit int) ([]byte, error) {
func (HTTP) Search(ctx context.Context, query string, limit int) ([]byte, error) {
hits, err := searchHits(query, "", "", limit)
if err != nil {
return nil, err
@@ -31,10 +33,13 @@ func (HTTP) Search(_ context.Context, query string, limit int) ([]byte, error) {
hits[i].Snippet = string(runes)
}
}
webOut := rank.Deduce(hits, query, "", false, func(q string) rank.SecondSource {
return lookupWeb(ctx, q)
})
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(toJSONOut(hits, query, "")); err != nil {
if err := enc.Encode(toJSONOut(hits, query, "", webOut)); err != nil {
return nil, err
}
return buf.Bytes(), nil
+4 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
)
const Usage = `usage: bin/brain/search.go "query" [--root facts|info] [--repo REPO] [-n N] [--json]
const Usage = `usage: bin/brain/search.go "query" [--root facts|info] [--repo REPO] [-n N] [--json] [--no-web]
bin/brain/search.go serve [port]
bin/brain/search.go --list-model`
@@ -17,6 +17,7 @@ type Options struct {
Limit int
JSONOut bool
ListModel bool
NoWeb bool
}
// ParseArgs reads flags. Unknown flags are an error: silently dropping them
@@ -54,6 +55,8 @@ func ParseArgs(args []string) (Options, error) {
return opt, fmt.Errorf("--hop is not implemented yet (needs File/FROM_FILE edges)")
case "--json":
opt.JSONOut = true
case "--no-web":
opt.NoWeb = true
case "--list-model":
opt.ListModel = true
default:
+43
View File
@@ -0,0 +1,43 @@
package rank
// SecondSource is the web-search block on a deduction answer.
// Kept apart from graph hits so "ours" and "not ours" stay visible.
type SecondSource struct {
Status string `json:"status"`
Note string `json:"note,omitempty"`
Cached bool `json:"cached,omitempty"`
Results []SecondSourceHit `json:"results,omitempty"`
}
type SecondSourceHit struct {
Rank int `json:"rank"`
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
Engine string `json:"engine"`
}
type WebFn func(query string) SecondSource
// ShouldEscalate is true when the default deduction path has no facts hit.
// `--root facts|info` is a single-root ask: do not mix in the web.
func ShouldEscalate(hits []Hit, rootFilter string) bool {
if rootFilter != "" {
return false
}
for _, h := range hits {
if h.Root == "facts" {
return false
}
}
return true
}
// Deduce returns the second-source block, or nil when web must not run.
func Deduce(hits []Hit, query, rootFilter string, noWeb bool, web WebFn) *SecondSource {
if noWeb || web == nil || !ShouldEscalate(hits, rootFilter) {
return nil
}
out := web(query)
return &out
}
+78
View File
@@ -0,0 +1,78 @@
package rank
import (
"strings"
"testing"
)
func TestShouldEscalateWhenNoFacts(t *testing.T) {
if !ShouldEscalate(nil, "") {
t.Fatal("empty local graph must escalate")
}
if !ShouldEscalate([]Hit{h("i", "info", "docs/a.md")}, "") {
t.Fatal("info-only must escalate (not confirmed)")
}
}
func TestShouldNotEscalateWhenFactsConfirm(t *testing.T) {
hits := []Hit{h("f", "facts", "docker ps x compose"), h("i", "info", "docs/a.md")}
if ShouldEscalate(hits, "") {
t.Fatal("facts hit is already confirmed; do not mix web")
}
}
func TestShouldNotEscalateWhenRootFilterSet(t *testing.T) {
if ShouldEscalate(nil, "facts") {
t.Fatal("--root facts must stay local")
}
if ShouldEscalate([]Hit{h("i", "info", "x")}, "info") {
t.Fatal("--root info must stay local")
}
}
func TestDeduceCallsWebOnlyWhenEscalating(t *testing.T) {
called := 0
web := func(q string) SecondSource {
called++
if q != "LadybugDB" {
t.Fatalf("query = %q", q)
}
return SecondSource{Status: "ok", Results: []SecondSourceHit{{Title: "t", URL: "http://example.com"}}}
}
got := Deduce([]Hit{h("i", "info", "x")}, "LadybugDB", "", false, web)
if called != 1 || got == nil || got.Status != "ok" {
t.Fatalf("got %+v called=%d", got, called)
}
}
func TestDeduceNilWhenFactsOrNoWeb(t *testing.T) {
web := func(string) SecondSource {
t.Fatal("web must not run")
return SecondSource{}
}
if Deduce([]Hit{h("f", "facts", "x")}, "q", "", false, web) != nil {
t.Fatal("facts")
}
if Deduce([]Hit{h("i", "info", "x")}, "q", "", true, web) != nil {
t.Fatal("--no-web")
}
if Deduce(nil, "q", "facts", false, web) != nil {
t.Fatal("--root facts")
}
if Deduce(nil, "q", "", false, nil) != nil {
t.Fatal("nil web fn")
}
}
func TestParseNoWeb(t *testing.T) {
opt, err := ParseArgs([]string{"query", "--no-web", "--json"})
if err != nil || !opt.NoWeb || !opt.JSONOut || opt.Query != "query" {
t.Fatalf("got %+v err=%v", opt, err)
}
}
func TestUsageNamesNoWeb(t *testing.T) {
if !strings.Contains(Usage, "--no-web") {
t.Fatalf("usage must name --no-web, got:\n%s", Usage)
}
}
+15 -6
View File
@@ -68,18 +68,25 @@ func runSearch(args []string) int {
}
}
webOut := rank.Deduce(results, query, root, opt.NoWeb, func(q string) rank.SecondSource {
return lookupWeb(context.Background(), q)
})
out := Dict{
{"query", query},
{"root_filter", root},
{"count", len(results)},
{"results", resultsToDicts(results)},
}
if webOut != nil {
out = append(out, KV{"web", secondToDict(*webOut)})
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
return b2i(enc.Encode(toJSONOut(results, query, root)))
return b2i(enc.Encode(toJSONOut(results, query, root, webOut)))
}
fmt.Print(toYAML(out, 0))
return 0
@@ -168,10 +175,11 @@ func rowsToHits(res *lbug.QueryResult) ([]Hit, error) {
// JSON output types
type jsonOut struct {
Query string `json:"query"`
RootFilter string `json:"root_filter"`
Count int `json:"count"`
Results []jsonHit `json:"results"`
Query string `json:"query"`
RootFilter string `json:"root_filter"`
Count int `json:"count"`
Results []jsonHit `json:"results"`
Web *rank.SecondSource `json:"web,omitempty"`
}
type jsonHit struct {
@@ -182,7 +190,7 @@ type jsonHit struct {
Snippet string `json:"snippet,omitempty"`
}
func toJSONOut(hits []Hit, query, rootFilter string) *jsonOut {
func toJSONOut(hits []Hit, query, rootFilter string, web *rank.SecondSource) *jsonOut {
out := make([]jsonHit, len(hits))
for i, h := range hits {
out[i] = jsonHit{
@@ -198,6 +206,7 @@ func toJSONOut(hits []Hit, query, rootFilter string) *jsonOut {
RootFilter: rootFilter,
Count: len(hits),
Results: out,
Web: web,
}
}
+56
View File
@@ -0,0 +1,56 @@
package brain
import (
"context"
"github.com/eSlider/2dph/internal/brain/rank"
"github.com/eSlider/2dph/internal/websearch"
)
func lookupWeb(ctx context.Context, query string) rank.SecondSource {
o := websearch.Lookup(ctx, query, websearch.LookupOpt{Limit: 5})
return toSecond(o)
}
func toSecond(o websearch.Output) rank.SecondSource {
hits := make([]rank.SecondSourceHit, 0, len(o.Results))
for _, h := range o.Results {
hits = append(hits, rank.SecondSourceHit{
Rank: h.Rank,
Title: h.Title,
URL: h.URL,
Snippet: h.Snippet,
Engine: h.Engine,
})
}
return rank.SecondSource{
Status: o.Status,
Note: o.Note,
Cached: o.Cached,
Results: hits,
}
}
func secondToDict(w rank.SecondSource) Dict {
d := Dict{
{"status", w.Status},
}
if w.Note != "" {
d = append(d, KV{"note", w.Note})
}
if w.Cached {
d = append(d, KV{"cached", true})
}
rows := make([]any, 0, len(w.Results))
for _, h := range w.Results {
rows = append(rows, Dict{
{"rank", h.Rank},
{"title", h.Title},
{"url", h.URL},
{"snippet", h.Snippet},
{"engine", h.Engine},
})
}
d = append(d, KV{"results", rows})
return d
}