Skip to content

Commit ae4a23a

Browse files
committed
feat(api): /v1/answer/pageindex endpoint with reasoning + streaming
Wire the PageIndex strategy through a dedicated answer endpoint on the existing /v1 router. The endpoint: - Owns the full RAG round-trip in one request: retrieval + answer + citations come back from a single agentic loop. No separate synthesis call — the model emits its answer inside the done action and we surface it as `answer` on the response. - Emits page-grounded citations. One citation per page range the agent fetched (deduplicated), each carrying start_page / end_page / section_ids plus an answer-span quote pulled via the existing SpanExtractor over the cited content. Falls back gracefully when the LLM declines a quote. - Persists every successful response to the existing replay store under the strategy's deterministic trace_token. The token's input set is sorted cited page ranges (not section IDs), and the strategy name is folded into the hash so page-based and section-based tokens for the same doc/model never collide. - Supports an opt-in reasoning trace (body field `reasoning:true` or query param `?reasoning=true`) that surfaces per-hop tool calls + args + tool-result chars + sections touched, captured via a new OnEvent hook on PageIndexStrategy. - Streams via Server-Sent Events when `stream:true` is set on the body. One event per tool call (get_document_structure, get_pages, done) so callers WATCH the navigation in real time, terminated by an `answer` event carrying the full JSON response payload. - Honors per-request overrides for max_hops and max_pages_per_fetch without mutating shared Deps. Disabled deployments (retrieval.pageindex.enabled=false or no LLM client) return 501; missing documents 404; bad bodies 400. Adds `RetrievalConfig.PageIndex` (PageIndexBlock) with defaults (Enabled=true, MaxHops=8, PageContentLimit=16000) and matching VLE_RETRIEVAL_PAGEINDEX_* env overrides. Validation rejects negative knobs and accepts "pageindex" as a retrieval strategy. cmd/engine/main.go registers the strategy via buildStrategy when retrieval.strategy=pageindex, AND wires a standalone PageIndexStrategy instance into the api.Deps used by the answer endpoint — so the endpoint is available regardless of which selection strategy the deployment runs by default. Test coverage: 12 end-to-end handler tests (happy path, reasoning trace via body field + query param, bad request, not found, disabled in two modes, no LLM, replay persistence verifying byte-equal response bytes, SSE event stream shape, per-request override caps the loop, TOC fallback). Plus 5 config tests for defaults + env overrides + validation. A PageIndexTreeLoader function field on Deps acts as a test seam so handler tests can run end-to-end via httptest with an in-memory tree, without a real Postgres backend.
1 parent a7cab8d commit ae4a23a

7 files changed

Lines changed: 1425 additions & 20 deletions

File tree

cmd/engine/main.go

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -196,24 +196,41 @@ func run() error {
196196
}
197197
q.Register(queue.KindIngestDocument, pipeline.Handler())
198198

199+
// /v1/answer/pageindex gets its OWN PageIndexStrategy instance,
200+
// independent of whatever selection strategy is configured in
201+
// retrieval.strategy. This way the endpoint is always available
202+
// (gated by retrieval.pageindex.enabled), even on a deployment
203+
// using chunked-tree as its default selection path.
204+
var pageIndexStrategy *retrieval.PageIndexStrategy
205+
if cfg.Retrieval.PageIndex.Enabled && llmClient != nil {
206+
pageIndexStrategy = buildPageIndexStrategy(cfg.Retrieval, llmClient, store)
207+
logger.Info("retrieval: pageindex answer endpoint enabled",
208+
"max_hops", pageIndexStrategy.MaxHops,
209+
"page_content_limit", pageIndexStrategy.PageContentLimit,
210+
"model_override", cfg.Retrieval.PageIndex.Model,
211+
)
212+
}
213+
199214
deps := api.Deps{
200-
Logger: logger,
201-
DB: pool,
202-
Storage: store,
203-
Queue: q,
204-
Strategy: strategy,
205-
Version: version,
206-
MultiDoc: multiDoc,
207-
LLM: llmClient,
208-
LLMModel: modelFor(cfg.LLM),
209-
AnswerSpan: cfg.Retrieval.AnswerSpan,
210-
Answer: cfg.Retrieval.Answer,
211-
Planner: planner,
212-
Planning: cfg.Retrieval.Planning,
213-
ReRanker: reRanker,
214-
ReRank: cfg.Retrieval.ReRank,
215-
Replay: replayStore,
216-
Abstain: cfg.Retrieval.Abstain,
215+
Logger: logger,
216+
DB: pool,
217+
Storage: store,
218+
Queue: q,
219+
Strategy: strategy,
220+
Version: version,
221+
MultiDoc: multiDoc,
222+
LLM: llmClient,
223+
LLMModel: modelFor(cfg.LLM),
224+
AnswerSpan: cfg.Retrieval.AnswerSpan,
225+
Answer: cfg.Retrieval.Answer,
226+
Planner: planner,
227+
Planning: cfg.Retrieval.Planning,
228+
ReRanker: reRanker,
229+
ReRank: cfg.Retrieval.ReRank,
230+
Replay: replayStore,
231+
Abstain: cfg.Retrieval.Abstain,
232+
PageIndexStrategy: pageIndexStrategy,
233+
PageIndex: cfg.Retrieval.PageIndex,
217234
}
218235

219236
srv := &http.Server{
@@ -365,11 +382,36 @@ func buildStrategy(c config.RetrievalConfig, client llmgate.Client, store storag
365382
}
366383
a.ModelOverride = c.Agentic.Model
367384
return a
385+
case "pageindex":
386+
return buildPageIndexStrategy(c, client, store)
368387
default:
369388
return retrieval.NewChunkedTree(client)
370389
}
371390
}
372391

392+
// buildPageIndexStrategy constructs the page-based agentic
393+
// strategy with the storage-backed PageLoader and the configured
394+
// caps. Used by buildStrategy when retrieval.strategy=pageindex AND
395+
// by the /v1/answer/pageindex endpoint setup (which wires its own
396+
// instance regardless of the selection strategy).
397+
//
398+
// The TOCProvider is left nil here. PR-A (toc-tree-builder) adds
399+
// documents.toc_tree + a DB-backed provider; until it lands the
400+
// strategy degrades to its synthesised view, which is the
401+
// documented fallback path.
402+
func buildPageIndexStrategy(c config.RetrievalConfig, client llmgate.Client, store storage.Storage) *retrieval.PageIndexStrategy {
403+
p := retrieval.NewPageIndexStrategy(client)
404+
p.PageLoader = storagePageLoader{s: store}
405+
if c.PageIndex.MaxHops > 0 {
406+
p.MaxHops = c.PageIndex.MaxHops
407+
}
408+
if c.PageIndex.PageContentLimit > 0 {
409+
p.PageContentLimit = c.PageIndex.PageContentLimit
410+
}
411+
p.ModelOverride = c.PageIndex.Model
412+
return p
413+
}
414+
373415
// storageFetcher adapts a storage.Storage to retrieval.ContentFetcher.
374416
// The agentic strategy reads section bodies one at a time, so we
375417
// materialize the full reader contents into a []byte here rather than
@@ -385,6 +427,23 @@ func (sf storageFetcher) Get(ctx context.Context, ref string) ([]byte, error) {
385427
return io.ReadAll(rc)
386428
}
387429

430+
// storagePageLoader adapts a storage.Storage to
431+
// retrieval.PageContentLoader. Mirrors storageFetcher but lives
432+
// behind a separate interface so the two callers (agentic /
433+
// pageindex) can be wired independently. The PageIndex strategy
434+
// materialises section bodies once per get_pages observation, so
435+
// reading the full reader into a []byte is the right shape.
436+
type storagePageLoader struct{ s storage.Storage }
437+
438+
func (l storagePageLoader) Load(ctx context.Context, ref string) ([]byte, error) {
439+
rc, _, err := l.s.Get(ctx, ref)
440+
if err != nil {
441+
return nil, err
442+
}
443+
defer rc.Close()
444+
return io.ReadAll(rc)
445+
}
446+
388447
// buildTLSConfig returns a *tls.Config when direct TLS is enabled, or nil
389448
// when the engine should serve plaintext (behind a proxy). Returning nil
390449
// leaves http.Server's TLSConfig unset, which is exactly what ListenAndServe

0 commit comments

Comments
 (0)