Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions internal/handler/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
Expand Down Expand Up @@ -157,8 +158,9 @@ func (h *QueryHandler) HandleQuery(w http.ResponseWriter, r *http.Request) {

// Use CostStrategy if available to get token usage + cost.
var (
ids []tree.SectionID
usage *retrieval.Usage
ids []tree.SectionID
usage *retrieval.Usage
evidence []retrieval.EvidencePage
)
if cs, ok := strategy.(retrieval.CostStrategy); ok {
result, err := cs.SelectWithCost(r.Context(), t, body.Query, budget)
Expand All @@ -172,6 +174,7 @@ func (h *QueryHandler) HandleQuery(w http.ResponseWriter, r *http.Request) {
}
ids = result.SelectedIDs
usage = &result.Usage
evidence = result.EvidencePages
} else {
var err error
ids, err = strategy.Select(r.Context(), t, body.Query, budget)
Expand All @@ -185,11 +188,21 @@ func (h *QueryHandler) HandleQuery(w http.ResponseWriter, r *http.Request) {
}
}

if body.MaxSections > 0 && len(ids) > body.MaxSections {
ids = ids[:body.MaxSections]
// A page-based strategy's evidence pages lead the list, as found: the
// page the Judge chose holds the answer, while the sections overlapping
// it come back in document order (HAL-1390). max_sections caps the
// combined list, so a small cap keeps the evidence, not the first
// sections of the document (HAL-3071).
sections := evidencePageSections(evidence)
if body.MaxSections > 0 {
if len(sections) > body.MaxSections {
sections = sections[:body.MaxSections]
}
if room := body.MaxSections - len(sections); len(ids) > room {
ids = ids[:room]
}
}

sections := make([]map[string]any, 0, len(ids))
for _, id := range ids {
sec := t.FindByID(id)
if sec == nil {
Expand Down Expand Up @@ -235,3 +248,27 @@ func (h *QueryHandler) HandleQuery(w http.ResponseWriter, r *http.Request) {
setTokenHeaders(w.Header(), resp)
writeJSON(w, http.StatusOK, resp)
}

// evidencePageSections turns a page-based strategy's evidence pages into
// response units: id "page_<n>", the owning section's title with the page
// ("<section> · p.<n>"), the page text as content. The same shape the
// standalone engine returns (internal/api), so clients and the benchmark
// read one contract from either binary.
func evidencePageSections(pages []retrieval.EvidencePage) []map[string]any {
out := make([]map[string]any, 0, len(pages))
for _, ep := range pages {
title := ep.Title
if title == "" {
title = fmt.Sprintf("Page %d", ep.Page)
}
out = append(out, map[string]any{
"id": fmt.Sprintf("page_%d", ep.Page),
"title": fmt.Sprintf("%s · p.%d", title, ep.Page),
"content": ep.Text,
"page": ep.Page,
"confidence": ep.Confidence,
"token_count": len(ep.Text) / 4,
})
}
return out
}
63 changes: 63 additions & 0 deletions internal/handler/query_evidence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package handler

import (
"context"
"testing"

"github.com/hallelx2/vectorless-engine/pkg/retrieval"
"github.com/hallelx2/vectorless-engine/pkg/tree"
)

// evidenceStrategy is a page-based strategy stub: it picks both sections
// in document order and names page 4 (in section B) as the evidence.
type evidenceStrategy struct{}

func (evidenceStrategy) Name() string { return "judgewalk" }

func (evidenceStrategy) Select(ctx context.Context, t *tree.Tree, q string, b retrieval.ContextBudget) ([]tree.SectionID, error) {
return []tree.SectionID{"sec_a", "sec_b"}, nil
}

func (evidenceStrategy) SelectWithCost(ctx context.Context, t *tree.Tree, q string, b retrieval.ContextBudget) (*retrieval.Result, error) {
return &retrieval.Result{
SelectedIDs: []tree.SectionID{"sec_a", "sec_b"},
EvidencePages: []retrieval.EvidencePage{{Page: 4, Title: "B", Text: "capex was $1,577 million", Confidence: 0.93}},
}, nil
}

func TestQueryLeadsWithEvidencePages(t *testing.T) {
h := newQueryStrategyHandler(evidenceStrategy{}, nil)
rec, resp := doQuery(t, h, `{"document_id":"doc_x","query":"capex?"}`)
if rec.Code != 200 {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
units := resp["sections"].([]any)
if len(units) != 3 {
t.Fatalf("want the evidence page plus both sections, got %d units", len(units))
}
first := units[0].(map[string]any)
if first["id"] != "page_4" || first["title"] != "B · p.4" || first["page"] != float64(4) {
t.Errorf("first unit = %v, want the evidence page", first)
}
if first["content"] != "capex was $1,577 million" {
t.Errorf("evidence page content = %v", first["content"])
}
}

// The bug HAL-3071 fixes: a small max_sections must keep the evidence, not
// the first sections of the document.
func TestMaxSectionsKeepsTheEvidence(t *testing.T) {
h := newQueryStrategyHandler(evidenceStrategy{}, nil)
_, resp := doQuery(t, h, `{"document_id":"doc_x","query":"capex?","max_sections":2}`)
units := resp["sections"].([]any)
if len(units) != 2 {
t.Fatalf("max_sections 2 returned %d units", len(units))
}
if units[0].(map[string]any)["id"] != "page_4" || units[1].(map[string]any)["id"] != "sec_a" {
t.Errorf("units = %v, want page_4 then sec_a", units)
}
_, one := doQuery(t, h, `{"document_id":"doc_x","query":"capex?","max_sections":1}`)
if u := one["sections"].([]any); len(u) != 1 || u[0].(map[string]any)["id"] != "page_4" {
t.Errorf("max_sections 1 = %v, want only the evidence page", u)
}
}
Loading