feat: escalate brain search to web when facts cannot confirm (#17)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user