feat: search --hop walks FROM_FILE to Person.
Parser no longer errors; hop 1 returns File, hop 3 reaches Person. Rebuild writes Leaf-[:FROM_FILE]->File so the walk is not empty on a fresh index (Gitea #17).
This commit is contained in:
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Usage = `usage: bin/brain/search.go "query" [--root facts|info] [--repo REPO] [-n N] [--json] [--no-web]
|
||||
const Usage = `usage: bin/brain/search.go "query" [--root facts|info] [--repo REPO] [-n N] [--hop N] [--json] [--no-web]
|
||||
bin/brain/search.go serve [port]
|
||||
bin/brain/search.go --list-model`
|
||||
|
||||
@@ -15,6 +15,7 @@ type Options struct {
|
||||
Root string
|
||||
Repo string
|
||||
Limit int
|
||||
Hop int
|
||||
JSONOut bool
|
||||
ListModel bool
|
||||
NoWeb bool
|
||||
@@ -22,8 +23,6 @@ type Options struct {
|
||||
|
||||
// ParseArgs reads flags. Unknown flags are an error: silently dropping them
|
||||
// meant `--hop 1` vanished and its argument `1` was appended to the query.
|
||||
// --hop is recognised so it cannot be swallowed; it is not implemented until
|
||||
// File/FROM_FILE edges exist.
|
||||
func ParseArgs(args []string) (Options, error) {
|
||||
opt := Options{Limit: 20}
|
||||
var queryArgs []string
|
||||
@@ -52,7 +51,15 @@ func ParseArgs(args []string) (Options, error) {
|
||||
}
|
||||
opt.Limit = n
|
||||
case "--hop":
|
||||
return opt, fmt.Errorf("--hop is not implemented yet (needs File/FROM_FILE edges)")
|
||||
i++
|
||||
n, err := strconv.Atoi(args[i])
|
||||
if err != nil || n < 1 {
|
||||
return opt, fmt.Errorf("--hop must be a positive integer, got %q", args[i])
|
||||
}
|
||||
if n > 3 {
|
||||
return opt, fmt.Errorf("--hop max is 3 (File → Commit → Person)")
|
||||
}
|
||||
opt.Hop = n
|
||||
case "--json":
|
||||
opt.JSONOut = true
|
||||
case "--no-web":
|
||||
|
||||
@@ -7,3 +7,30 @@ const FTSStmt = "CALL QUERY_FTS_INDEX('Leaf', 'id', $q) " +
|
||||
|
||||
const VecStmt = "CALL QUERY_VECTOR_INDEX('Leaf', 'Leaf_vec', $q, $n) " +
|
||||
"RETURN node.id, node.text, node.root, node.source, distance ORDER BY distance LIMIT $n"
|
||||
|
||||
// HopStmt is the Cypher walk from a search hit. Depth 1 = File, 2 = Commit, 3 = Person.
|
||||
func HopStmt(depth int) string {
|
||||
switch depth {
|
||||
case 1:
|
||||
return "MATCH (l:Leaf {id:$id})-[:FROM_FILE]->(f:File) RETURN f.id, f.path, 1"
|
||||
case 2:
|
||||
return "MATCH (l:Leaf {id:$id})-[:FROM_FILE]->(f:File)-[:HAS_VERSION]->(c:Commit) RETURN c.id, c.subject, 2"
|
||||
case 3:
|
||||
return "MATCH (l:Leaf {id:$id})-[:FROM_FILE]->(f:File)-[:HAS_VERSION]->(c:Commit)-[:AUTHORED]->(p:Person) RETURN p.id, p.name, 3"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func HopLabel(depth int) string {
|
||||
switch depth {
|
||||
case 1:
|
||||
return "File"
|
||||
case 2:
|
||||
return "Commit"
|
||||
case 3:
|
||||
return "Person"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,22 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type HopNode struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Name string `json:"name"`
|
||||
Depth int `json:"depth"`
|
||||
}
|
||||
|
||||
// Hit is one search result, mirroring the python script's dict shape.
|
||||
type Hit struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Root string `json:"root"`
|
||||
Source string `json:"-"`
|
||||
Score float64 `json:"score"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Root string `json:"root"`
|
||||
Source string `json:"-"`
|
||||
Score float64 `json:"score"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
Hops []HopNode `json:"hops,omitempty"`
|
||||
}
|
||||
|
||||
// rrfK dampens the contribution of low ranks; same constant as kblib.py.
|
||||
|
||||
@@ -91,15 +91,41 @@ func TestHybridKeepsVectorScoreForSharedHit(t *testing.T) {
|
||||
}
|
||||
|
||||
// The old parser dropped unknown flags and appended their arguments to the
|
||||
// query, so `search "q" --hop 1` searched for "q 1". --hop is not implemented
|
||||
// here (needs File edges); it must still fail closed instead of changing q.
|
||||
// query, so `search "q" --hop 1` searched for "q 1". --hop must stay a flag.
|
||||
func TestParseHopIsNotSwallowedIntoTheQuery(t *testing.T) {
|
||||
_, err := ParseArgs([]string{"what runs on arc-2", "--hop", "1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected --hop to error (not implemented), not be swallowed")
|
||||
opt, err := ParseArgs([]string{"what runs on arc-2", "--hop", "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--hop") {
|
||||
t.Fatalf("error should name --hop, got %v", err)
|
||||
if opt.Query != "what runs on arc-2" {
|
||||
t.Fatalf("query swallowed hop arg: %q", opt.Query)
|
||||
}
|
||||
if opt.Hop != 1 {
|
||||
t.Fatalf("hop = %d, want 1", opt.Hop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHopMaxIsThree(t *testing.T) {
|
||||
if _, err := ParseArgs([]string{"q", "--hop", "4"}); err == nil {
|
||||
t.Fatal("expected --hop 4 to error")
|
||||
}
|
||||
opt, err := ParseArgs([]string{"q", "--hop", "3"})
|
||||
if err != nil || opt.Hop != 3 {
|
||||
t.Fatalf("hop 3: %+v err=%v", opt, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHopStmtWalksFromFile(t *testing.T) {
|
||||
s := HopStmt(1)
|
||||
if !strings.Contains(s, "FROM_FILE") || !strings.Contains(s, "File") {
|
||||
t.Fatalf("hop 1 must walk FROM_FILE, got %q", s)
|
||||
}
|
||||
s3 := HopStmt(3)
|
||||
if !strings.Contains(s3, "HAS_VERSION") || !strings.Contains(s3, "AUTHORED") || !strings.Contains(s3, "Person") {
|
||||
t.Fatalf("hop 3 must reach Person, got %q", s3)
|
||||
}
|
||||
if HopLabel(1) != "File" || HopLabel(3) != "Person" {
|
||||
t.Fatal("hop labels")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,12 @@ func runSearch(args []string) int {
|
||||
fmt.Fprintf(os.Stderr, "search: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if opt.Hop > 0 {
|
||||
if err := attachHops(hits, opt.Hop); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "hop: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
results := hits
|
||||
for i := range results {
|
||||
@@ -108,6 +114,44 @@ func searchHits(query, root, repo string, limit int) ([]Hit, error) {
|
||||
return rank.RankAndFilter(fts, vec, root, repo, limit), nil
|
||||
}
|
||||
|
||||
func attachHops(hits []Hit, n int) error {
|
||||
if conn == nil {
|
||||
return fmt.Errorf("brain not open")
|
||||
}
|
||||
for i := range hits {
|
||||
var hops []rank.HopNode
|
||||
for d := 1; d <= n; d++ {
|
||||
stmt, err := conn.Prepare(rank.HopStmt(d))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := conn.Execute(stmt, map[string]any{"id": hits[i].ID})
|
||||
stmt.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for res.HasNext() {
|
||||
row, err := res.Next()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vals, err := row.GetAsSlice()
|
||||
if err != nil || len(vals) < 3 {
|
||||
continue
|
||||
}
|
||||
hops = append(hops, rank.HopNode{
|
||||
ID: fmt.Sprint(vals[0]),
|
||||
Label: rank.HopLabel(d),
|
||||
Name: fmt.Sprint(vals[1]),
|
||||
Depth: int(asInt(vals[2])),
|
||||
})
|
||||
}
|
||||
}
|
||||
hits[i].Hops = hops
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func b2i(err error) int {
|
||||
if err != nil {
|
||||
return 1
|
||||
@@ -183,11 +227,12 @@ type jsonOut struct {
|
||||
}
|
||||
|
||||
type jsonHit struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Root string `json:"root"`
|
||||
Score float64 `json:"score"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Root string `json:"root"`
|
||||
Score float64 `json:"score"`
|
||||
Snippet string `json:"snippet,omitempty"`
|
||||
Hops []rank.HopNode `json:"hops,omitempty"`
|
||||
}
|
||||
|
||||
func toJSONOut(hits []Hit, query, rootFilter string, web *rank.SecondSource) *jsonOut {
|
||||
@@ -199,6 +244,7 @@ func toJSONOut(hits []Hit, query, rootFilter string, web *rank.SecondSource) *js
|
||||
Root: h.Root,
|
||||
Score: h.Score,
|
||||
Snippet: h.Snippet,
|
||||
Hops: h.Hops,
|
||||
}
|
||||
}
|
||||
return &jsonOut{
|
||||
@@ -222,6 +268,18 @@ func resultsToDicts(hits []Hit) []any {
|
||||
if h.Snippet != "" {
|
||||
d = append(d, KV{"snippet", h.Snippet})
|
||||
}
|
||||
if len(h.Hops) > 0 {
|
||||
nodes := make([]any, len(h.Hops))
|
||||
for j, n := range h.Hops {
|
||||
nodes[j] = Dict{
|
||||
{"id", n.ID},
|
||||
{"label", n.Label},
|
||||
{"name", n.Name},
|
||||
{"depth", n.Depth},
|
||||
}
|
||||
}
|
||||
d = append(d, KV{"hops", nodes})
|
||||
}
|
||||
out[i] = d
|
||||
}
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user