feat: search --hop walks FROM_FILE to Person. (#30)
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user