@@ -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.
0 commit comments