Skip to content

Commit 1cbc94d

Browse files
authored
Merge pull request #32 from hallelx2/feat/pageindex-confidence-gate
feat(retrieval): confidence-gate + dedup page-based citations
2 parents cbd46f5 + c76a386 commit 1cbc94d

14 files changed

Lines changed: 1037 additions & 73 deletions

cmd/engine/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,9 @@ func buildPageIndexStrategy(c config.RetrievalConfig, client llmgate.Client, sto
412412
if c.PageIndex.PageContentLimit > 0 {
413413
p.PageContentLimit = c.PageIndex.PageContentLimit
414414
}
415+
if c.PageIndex.MaxCitations > 0 {
416+
p.MaxCitations = c.PageIndex.MaxCitations
417+
}
415418
p.ModelOverride = c.PageIndex.Model
416419
return p
417420
}

cmd/server/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,9 @@ func buildPageIndexStrategy(c enginecfg.RetrievalConfig, client llmgate.Client,
488488
if c.PageIndex.PageContentLimit > 0 {
489489
p.PageContentLimit = c.PageIndex.PageContentLimit
490490
}
491+
if c.PageIndex.MaxCitations > 0 {
492+
p.MaxCitations = c.PageIndex.MaxCitations
493+
}
491494
p.ModelOverride = c.PageIndex.Model
492495
return p
493496
}

config.example.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,16 @@ retrieval:
286286
# flagship model's context window. Higher values risk burning
287287
# context budget on stray full-document fetches.
288288
page_content_limit: 16000
289+
# Cap on how many distinct page ranges the FINAL answer may
290+
# cite. A confidence backstop, not a navigation limit: the
291+
# model may read as many pages as max_hops allows, but the
292+
# citation set it commits to is bounded. FinanceBench data
293+
# shows the failure mode is "spray ~5 low-confidence ranges ->
294+
# miss all" while a single confident pick scores perfectly;
295+
# capping the final set tames the spray. 3 keeps a genuinely
296+
# multi-location answer (e.g. a figure + its footnote) while
297+
# cutting the low-confidence tail. Env: VLE_/VLS_RETRIEVAL_PAGEINDEX_MAX_CITATIONS.
298+
max_citations: 3
289299
# Override the navigation-loop model; empty inherits the
290300
# request's model (which itself falls back to the engine
291301
# default). Most deployments leave this blank — navigation

internal/api/pageindex.go

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ func (d Deps) handleAnswerPageIndex(w http.ResponseWriter, r *http.Request) {
175175
"citations": citations,
176176
"strategy": perReq.Name(),
177177
"model": budget.ModelName,
178+
"confidence": res.Confidence,
178179
"hops_taken": res.HopsTaken,
179180
"usage": map[string]any{
180181
"input_tokens": res.Usage.InputTokens,
@@ -268,6 +269,7 @@ func (d Deps) serveAnswerPageIndexStream(w http.ResponseWriter, r *http.Request,
268269
"citations": citations,
269270
"strategy": strat.Name(),
270271
"model": budget.ModelName,
272+
"confidence": res.Confidence,
271273
"hops_taken": res.HopsTaken,
272274
"usage": map[string]any{
273275
"input_tokens": res.Usage.InputTokens,
@@ -298,35 +300,35 @@ func (d Deps) buildPageIndexCitations(ctx context.Context, t *tree.Tree, res *re
298300
if res == nil {
299301
return nil
300302
}
301-
// Build a citation per UNIQUE page range present in PagesRead.
302-
// The set of pages the model "read" is a superset of what it
303-
// cited — some get_pages calls don't end up in the final
304-
// cited_pages list — but the union is the right cone of trust
305-
// to surface as evidence. The trace token is computed over
306-
// only the strictly-cited ranges, which the strategy already
307-
// has, so citation drift doesn't break replay.
308-
seen := make(map[[2]int]struct{}, len(res.PagesRead))
309-
citations := make([]map[string]any, 0, len(res.PagesRead))
310-
311-
for _, pr := range res.PagesRead {
312-
key := [2]int{pr.StartPage, pr.EndPage}
313-
if _, dup := seen[key]; dup {
314-
continue
303+
// Source of truth for citations is the FINAL cited-range set
304+
// (res.CitedPages) — already deduped and capped to MaxCitations by
305+
// the strategy. This is what makes a confident single-pick answer
306+
// surface ONE citation even when it skimmed several pages to find
307+
// it: PagesRead is the navigation footprint (a superset), not the
308+
// commitment. Fall back to PagesRead only when nothing was cited
309+
// (refusal) or the run was hop-capped without a done — the trace
310+
// token is keyed on the cited ranges either way, so this never
311+
// breaks replay.
312+
sources := retrieval.CitationSources(res)
313+
citations := make([]map[string]any, 0, len(sources))
314+
315+
for _, src := range sources {
316+
sectionIDs := src.SectionIDs
317+
if sectionIDs == nil {
318+
sectionIDs = retrieval.SectionIDsOverlapping(t, src.Start, src.End)
315319
}
316-
seen[key] = struct{}{}
317-
318320
c := map[string]any{
319-
"start_page": pr.StartPage,
320-
"end_page": pr.EndPage,
321-
"section_ids": pr.SectionIDs,
321+
"start_page": src.Start,
322+
"end_page": src.End,
323+
"section_ids": sectionIDs,
322324
}
323325

324326
// Quote extraction is best-effort: an LLM blip or empty
325327
// content returns no quote, which is a normal degradation
326328
// path. We materialise the cited content from storage and
327329
// run one SpanExtractor call per citation.
328330
if d.LLM != nil {
329-
content := d.materialiseCitedContent(ctx, t, pr.SectionIDs)
331+
content := d.materialiseCitedContent(ctx, t, sectionIDs)
330332
if strings.TrimSpace(content) != "" {
331333
ext := d.pageIndexSpanExtractor(requestModel)
332334
span, _, err := ext.Extract(ctx, content, query)

internal/api/pageindex_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,107 @@ func TestHandleAnswerPageIndexReasoningTrace(t *testing.T) {
261261
}
262262
}
263263

264+
// TestHandleAnswerPageIndexDedupAndCapCitations is the end-to-end
265+
// proof of the bench-facing fix: a done that sprays the SAME range
266+
// five times plus extras must produce a citations[] array that is
267+
// deduped and capped at MaxCitations — no duplicate page ranges, no
268+
// repeated section ids across citations, and confidence surfaced.
269+
// This is the API-layer mirror of the strategy's dedup test and the
270+
// reason precision@5 stops deflating.
271+
func TestHandleAnswerPageIndexDedupAndCapCitations(t *testing.T) {
272+
t.Parallel()
273+
274+
// Read one range, then a done that cites [1,2] five times plus
275+
// two more distinct ranges and a confidence. MaxCitations=3.
276+
deps, _, _ := newTestDeps(t,
277+
`{"tool":"get_pages","start_page":1,"end_page":2,"reasoning":"skim"}`,
278+
`{"tool":"done","answer":"sprayed","confidence":0.8,"cited_pages":[[1,2],[1,2],[1,2],[1,2],[1,2],[3,4],[8,9]]}`,
279+
)
280+
281+
body := strings.NewReader(`{"document_id":"doc_x","query":"q"}`)
282+
req := httptest.NewRequest(http.MethodPost, "/v1/answer/pageindex", body)
283+
rec := httptest.NewRecorder()
284+
pageIndexHandlerRouter(deps).ServeHTTP(rec, req)
285+
if rec.Code != http.StatusOK {
286+
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
287+
}
288+
var resp map[string]any
289+
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
290+
t.Fatalf("unmarshal: %v", err)
291+
}
292+
293+
cits, ok := resp["citations"].([]any)
294+
if !ok {
295+
t.Fatalf("citations missing: %v", resp["citations"])
296+
}
297+
// Capped at MaxCitations=3, and every page range distinct.
298+
if len(cits) > 3 {
299+
t.Errorf("citations must be capped at 3, got %d", len(cits))
300+
}
301+
seenRange := map[[2]int]int{}
302+
for _, raw := range cits {
303+
c := raw.(map[string]any)
304+
key := [2]int{int(c["start_page"].(float64)), int(c["end_page"].(float64))}
305+
seenRange[key]++
306+
}
307+
for key, n := range seenRange {
308+
if n > 1 {
309+
t.Errorf("citation page range %v appears %d times; must be deduped", key, n)
310+
}
311+
}
312+
// The three distinct ranges all survive (under the cap).
313+
for _, want := range [][2]int{{1, 2}, {3, 4}, {8, 9}} {
314+
if seenRange[want] == 0 {
315+
t.Errorf("expected a citation for range %v, citations: %v", want, cits)
316+
}
317+
}
318+
// confidence surfaces on the response.
319+
if conf, ok := resp["confidence"].(float64); !ok || conf != 0.8 {
320+
t.Errorf("response confidence = %v, want 0.8", resp["confidence"])
321+
}
322+
}
323+
324+
// TestHandleAnswerPageIndexConfidentSingleCitation is the happy half
325+
// at the API layer: a confident single-range done — even after the
326+
// model skimmed several pages — surfaces exactly ONE citation. This
327+
// is the f1=1.0 commit case, and the fix that stops a multi-page
328+
// navigation footprint from leaking into citations[].
329+
func TestHandleAnswerPageIndexConfidentSingleCitation(t *testing.T) {
330+
t.Parallel()
331+
332+
// The model reads pages 1-2 AND 8-9 while searching, but commits
333+
// to a single cited range [8,9]. citations[] must be just [8,9].
334+
deps, _, _ := newTestDeps(t,
335+
`{"tool":"get_pages","start_page":1,"end_page":2,"reasoning":"check setup"}`,
336+
`{"tool":"get_pages","start_page":8,"end_page":9,"reasoning":"check debt"}`,
337+
`{"tool":"done","answer":"Debt is on 8-9.","confidence":0.95,"cited_pages":[[8,9]],"reasoning":"clear"}`,
338+
)
339+
340+
body := strings.NewReader(`{"document_id":"doc_x","query":"debt?"}`)
341+
req := httptest.NewRequest(http.MethodPost, "/v1/answer/pageindex", body)
342+
rec := httptest.NewRecorder()
343+
pageIndexHandlerRouter(deps).ServeHTTP(rec, req)
344+
if rec.Code != http.StatusOK {
345+
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
346+
}
347+
var resp map[string]any
348+
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
349+
350+
cits, ok := resp["citations"].([]any)
351+
if !ok || len(cits) != 1 {
352+
t.Fatalf("confident single pick must yield exactly ONE citation, got %v", resp["citations"])
353+
}
354+
first := cits[0].(map[string]any)
355+
if first["start_page"].(float64) != 8 || first["end_page"].(float64) != 9 {
356+
t.Errorf("citation = %v-%v, want 8-9", first["start_page"], first["end_page"])
357+
}
358+
// pages_read still records the full navigation footprint (both
359+
// reads) — only citations[] is tightened to the commitment.
360+
if pages, ok := resp["pages_read"].([]any); !ok || len(pages) != 2 {
361+
t.Errorf("pages_read must keep the full footprint (2 reads), got %v", resp["pages_read"])
362+
}
363+
}
364+
264365
// TestHandleAnswerPageIndexReasoningTraceQueryParam: the
265366
// ?reasoning=true query param is an alternative to the body field.
266367
// Some clients prefer it for GET-friendliness when prototyping.

internal/config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,16 @@ func applyEnvOverrides(c *Config) {
326326
if v := firstEnv("VLS_INGEST_MODE", "VLE_INGEST_MODE"); v != "" {
327327
c.Engine.Ingest.Mode = v
328328
}
329+
// PageIndex final-citation cap. Forwarded so the spray backstop can
330+
// be tuned on the live server without a config edit (e.g. tighten
331+
// to 1 to force single-citation answers, or relax for a doc set
332+
// with many genuinely multi-location questions). VLS_ wins over
333+
// VLE_; a garbled or negative value leaves the engine default (3).
334+
if v := firstEnv("VLS_RETRIEVAL_PAGEINDEX_MAX_CITATIONS", "VLE_RETRIEVAL_PAGEINDEX_MAX_CITATIONS"); v != "" {
335+
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
336+
c.Engine.Retrieval.PageIndex.MaxCitations = n
337+
}
338+
}
329339
// Total-parse timeout (seconds). Forwarded so the deployed server
330340
// honours a tuned parse deadline without a secret/config edit — the
331341
// outermost robustness valve against a parse that hangs (pre-LLM,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package config
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
// TestForwardPageIndexMaxCitations covers the server-config
9+
// pass-through for the PageIndex final-citation cap: the operator can
10+
// set it via VLS_ or VLE_ and have it reach the embedded engine
11+
// config, with VLS_ winning when both are present and a garbled value
12+
// preserving the engine default.
13+
func TestForwardPageIndexMaxCitations(t *testing.T) {
14+
prevVLS := os.Getenv("VLS_RETRIEVAL_PAGEINDEX_MAX_CITATIONS")
15+
prevVLE := os.Getenv("VLE_RETRIEVAL_PAGEINDEX_MAX_CITATIONS")
16+
defer func() {
17+
os.Setenv("VLS_RETRIEVAL_PAGEINDEX_MAX_CITATIONS", prevVLS)
18+
os.Setenv("VLE_RETRIEVAL_PAGEINDEX_MAX_CITATIONS", prevVLE)
19+
}()
20+
21+
// Engine default is 3 with no env set.
22+
os.Unsetenv("VLS_RETRIEVAL_PAGEINDEX_MAX_CITATIONS")
23+
os.Unsetenv("VLE_RETRIEVAL_PAGEINDEX_MAX_CITATIONS")
24+
cfg := Default()
25+
applyEnvOverrides(&cfg)
26+
if cfg.Engine.Retrieval.PageIndex.MaxCitations != 3 {
27+
t.Errorf("default max_citations = %d, want 3", cfg.Engine.Retrieval.PageIndex.MaxCitations)
28+
}
29+
30+
// VLE_ alone forwards through.
31+
os.Setenv("VLE_RETRIEVAL_PAGEINDEX_MAX_CITATIONS", "2")
32+
cfg2 := Default()
33+
applyEnvOverrides(&cfg2)
34+
if cfg2.Engine.Retrieval.PageIndex.MaxCitations != 2 {
35+
t.Errorf("VLE_ forward: max_citations = %d, want 2", cfg2.Engine.Retrieval.PageIndex.MaxCitations)
36+
}
37+
38+
// VLS_ wins over VLE_.
39+
os.Setenv("VLS_RETRIEVAL_PAGEINDEX_MAX_CITATIONS", "1")
40+
cfg3 := Default()
41+
applyEnvOverrides(&cfg3)
42+
if cfg3.Engine.Retrieval.PageIndex.MaxCitations != 1 {
43+
t.Errorf("VLS_ should win: max_citations = %d, want 1", cfg3.Engine.Retrieval.PageIndex.MaxCitations)
44+
}
45+
46+
// Garbled value preserves the engine default (3), does not zero it.
47+
os.Unsetenv("VLS_RETRIEVAL_PAGEINDEX_MAX_CITATIONS")
48+
os.Setenv("VLE_RETRIEVAL_PAGEINDEX_MAX_CITATIONS", "heaps")
49+
cfg4 := Default()
50+
applyEnvOverrides(&cfg4)
51+
if cfg4.Engine.Retrieval.PageIndex.MaxCitations != 3 {
52+
t.Errorf("garbage value should preserve default 3, got %d", cfg4.Engine.Retrieval.PageIndex.MaxCitations)
53+
}
54+
}

internal/handler/answer_pageindex.go

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ func (h *AnswerPageIndexHandler) HandleAnswerPageIndex(w http.ResponseWriter, r
200200
"citations": citations,
201201
"strategy": perReq.Name(),
202202
"model": budget.ModelName,
203+
"confidence": res.Confidence,
203204
"hops_taken": res.HopsTaken,
204205
"usage": map[string]any{
205206
"input_tokens": res.Usage.InputTokens,
@@ -281,6 +282,7 @@ func (h *AnswerPageIndexHandler) serveStream(w http.ResponseWriter, r *http.Requ
281282
"citations": citations,
282283
"strategy": strat.Name(),
283284
"model": budget.ModelName,
285+
"confidence": res.Confidence,
284286
"hops_taken": res.HopsTaken,
285287
"usage": map[string]any{
286288
"input_tokens": res.Usage.InputTokens,
@@ -296,32 +298,33 @@ func (h *AnswerPageIndexHandler) serveStream(w http.ResponseWriter, r *http.Requ
296298
emitSSE("answer", final)
297299
}
298300

299-
// buildCitations transforms the strategy's PagesRead + the section
300-
// tree into the response's citations array: one citation per unique
301-
// cited page range, each carrying the overlapping section IDs and a
302-
// best-effort grounding quote extracted from the cited content.
301+
// buildCitations transforms the strategy's FINAL cited ranges + the
302+
// section tree into the response's citations array: one citation per
303+
// cited page range (deduped + capped by the strategy), each carrying
304+
// the overlapping section IDs and a best-effort grounding quote
305+
// extracted from the cited content. Falls back to the PagesRead
306+
// footprint when nothing was cited (refusal / hop-capped run) so the
307+
// caller still sees where the model looked.
303308
func (h *AnswerPageIndexHandler) buildCitations(ctx context.Context, t *tree.Tree, res *retrieval.Result, query, requestModel string) []map[string]any {
304309
if res == nil {
305310
return nil
306311
}
307-
seen := make(map[[2]int]struct{}, len(res.PagesRead))
308-
citations := make([]map[string]any, 0, len(res.PagesRead))
312+
sources := retrieval.CitationSources(res)
313+
citations := make([]map[string]any, 0, len(sources))
309314

310-
for _, pr := range res.PagesRead {
311-
key := [2]int{pr.StartPage, pr.EndPage}
312-
if _, dup := seen[key]; dup {
313-
continue
315+
for _, src := range sources {
316+
sectionIDs := src.SectionIDs
317+
if sectionIDs == nil {
318+
sectionIDs = retrieval.SectionIDsOverlapping(t, src.Start, src.End)
314319
}
315-
seen[key] = struct{}{}
316-
317320
c := map[string]any{
318-
"start_page": pr.StartPage,
319-
"end_page": pr.EndPage,
320-
"section_ids": pr.SectionIDs,
321+
"start_page": src.Start,
322+
"end_page": src.End,
323+
"section_ids": sectionIDs,
321324
}
322325

323326
if h.llm != nil {
324-
content := h.materialiseCitedContent(ctx, t, pr.SectionIDs)
327+
content := h.materialiseCitedContent(ctx, t, sectionIDs)
325328
if strings.TrimSpace(content) != "" {
326329
ext := h.spanExtractor(requestModel)
327330
span, _, err := ext.Extract(ctx, content, query)

openapi.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,6 +1118,20 @@ components:
11181118
enum: [pageindex]
11191119
model:
11201120
type: string
1121+
confidence:
1122+
type: number
1123+
format: float
1124+
minimum: 0
1125+
maximum: 1
1126+
description: |
1127+
The agent's self-reported confidence in the answer +
1128+
citation set, in [0,1], taken from the terminal `done`
1129+
tool call. 0 means the model did not report one (it is
1130+
NOT a "definitely wrong" signal). Surfaced so callers
1131+
can treat a low-confidence answer more cautiously; it
1132+
does not suppress the answer — even an unsure agent
1133+
still returns its single best citation rather than a
1134+
spray of hedged ones.
11211135
hops_taken:
11221136
type: integer
11231137
description: |

0 commit comments

Comments
 (0)