Skip to content

Commit c15175a

Browse files
authored
api: emit canonical heading path on treewalk citations (HAL-70) (#41)
* api: emit canonical heading path (title_path) on treewalk citations Citations carried only {start_page, end_page, section_ids, quote} — no structural heading path. The bench's path_correct@1 reads a title_path it could only reconstruct from the parser's chunk titles, which are the wrong vocabulary, so the metric was structurally 0%. buildTreeWalkCitations now resolves the document's LLM TOC tree (via the strategy's TOC provider) into a section-ID -> heading-path map (tree.BuildHeadingPaths, HAL-109) and attaches the primary section's heading path as title_path on each citation. Degrades cleanly: no TOC persisted -> field omitted, prior behaviour unchanged. Part of HAL-70. * api: log non-ErrNoTOC failures in headingPathsForDoc (review) GetTOC errors were all silently treated as 'no heading paths'. Now retrieval.ErrNoTOC (the expected missing-TOC case) stays silent while any other error is logged at debug for observability — still degrading gracefully. Per Sourcery review on #40.
1 parent d5881ec commit c15175a

2 files changed

Lines changed: 164 additions & 0 deletions

File tree

internal/api/treewalk.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,15 @@ func (d Deps) buildTreeWalkCitations(ctx context.Context, t *tree.Tree, res *ret
310310
sources := retrieval.CitationSources(res)
311311
citations := make([]map[string]any, 0, len(sources))
312312

313+
// Canonical heading paths (section ID → logical heading path), resolved
314+
// from the document's LLM TOC tree by page-range containment (HAL-109).
315+
// This is what makes a citation carry a real STRUCTURAL path
316+
// ("Item 8" → "Balance Sheet") rather than leaving the consumer to
317+
// reverse-engineer one from the parser's chunk titles. Nil/empty when no
318+
// TOC was persisted — the citation then simply omits title_path and the
319+
// consumer falls back to its prior behaviour.
320+
headingPaths := d.headingPathsForDoc(ctx, t)
321+
313322
for _, src := range sources {
314323
sectionIDs := src.SectionIDs
315324
if sectionIDs == nil {
@@ -321,6 +330,16 @@ func (d Deps) buildTreeWalkCitations(ctx context.Context, t *tree.Tree, res *ret
321330
"section_ids": sectionIDs,
322331
}
323332

333+
// Attach the heading path of the citation's primary (first, i.e.
334+
// earliest-page) section. This mirrors how a consumer anchors a
335+
// page-range citation to one structural location, and is exactly
336+
// the field the bench's path-correctness metric reads.
337+
if len(sectionIDs) > 0 {
338+
if hp := headingPaths[sectionIDs[0]]; len(hp) > 0 {
339+
c["title_path"] = hp
340+
}
341+
}
342+
324343
// Quote extraction is best-effort: an LLM blip or empty
325344
// content returns no quote, which is a normal degradation
326345
// path. We materialise the cited content from storage and
@@ -354,6 +373,44 @@ func (d Deps) buildTreeWalkCitations(ctx context.Context, t *tree.Tree, res *ret
354373
return citations
355374
}
356375

376+
// headingPathsForDoc resolves the canonical section-ID → heading-path
377+
// map for the document, reading the persisted LLM TOC tree through the
378+
// strategy's TOC provider and reconciling it with the section tree via
379+
// tree.BuildHeadingPaths (HAL-109).
380+
//
381+
// Every failure mode degrades to nil (no heading paths): no strategy /
382+
// provider wired, no TOC persisted (retrieval.ErrNoTOC), a fetch error,
383+
// or unparseable TOC JSON. A nil map is safe to index — citations then
384+
// omit title_path and the consumer falls back to its prior behaviour, so
385+
// this never makes a response worse than before the TOC was available.
386+
func (d Deps) headingPathsForDoc(ctx context.Context, t *tree.Tree) map[tree.SectionID][]string {
387+
if t == nil || t.Root == nil || d.TreeWalkStrategy == nil || d.TreeWalkStrategy.TOC == nil {
388+
return nil
389+
}
390+
raw, err := d.TreeWalkStrategy.TOC.GetTOC(ctx, t.DocumentID)
391+
if err != nil {
392+
// retrieval.ErrNoTOC is the expected "no TOC persisted yet" signal —
393+
// silent. Any other error (DB/transport) is an operational issue worth
394+
// surfacing for diagnosis, even though we still degrade to no heading
395+
// paths rather than failing the request.
396+
if !errors.Is(err, retrieval.ErrNoTOC) {
397+
d.Logger.Debug("answer/treewalk: TOC fetch failed; citations omit heading paths",
398+
"err", err, "document_id", t.DocumentID)
399+
}
400+
return nil
401+
}
402+
if len(raw) == 0 {
403+
return nil
404+
}
405+
var nodes []tree.TOCNode
406+
if err := json.Unmarshal(raw, &nodes); err != nil {
407+
d.Logger.Warn("answer/treewalk: TOC parse failed; citations omit heading paths",
408+
"err", err, "document_id", t.DocumentID)
409+
return nil
410+
}
411+
return tree.BuildHeadingPaths(t.Root, nodes)
412+
}
413+
357414
// materialiseCitedContent loads + concatenates every cited
358415
// section's content. Used for answer-span extraction over the
359416
// pages the model relied on, so the quote can have real byte
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"log/slog"
7+
"reflect"
8+
"testing"
9+
10+
"github.com/hallelx2/vectorless-engine/pkg/retrieval"
11+
"github.com/hallelx2/vectorless-engine/pkg/tree"
12+
)
13+
14+
// stubTOCProvider returns canned TOC bytes (or an error) for the
15+
// citation builder's heading-path lookup.
16+
type stubTOCProvider struct {
17+
raw []byte
18+
err error
19+
}
20+
21+
func (s stubTOCProvider) GetTOC(context.Context, tree.DocumentID) ([]byte, error) {
22+
return s.raw, s.err
23+
}
24+
25+
// citationTestTOC mirrors buildTreeWalkTestTree's page layout as a
26+
// logical outline: Setup{Install 1-2, Configuration 3-4},
27+
// Usage{Querying 5-7, Debt 8-9}.
28+
func citationTestTOC(t *testing.T) []byte {
29+
t.Helper()
30+
toc := []tree.TOCNode{
31+
{Title: "Setup", StartPage: 1, EndPage: 4, Nodes: []tree.TOCNode{
32+
{Title: "Install", StartPage: 1, EndPage: 2},
33+
{Title: "Configuration", StartPage: 3, EndPage: 4},
34+
}},
35+
{Title: "Usage", StartPage: 5, EndPage: 9, Nodes: []tree.TOCNode{
36+
{Title: "Querying", StartPage: 5, EndPage: 7},
37+
{Title: "Debt", StartPage: 8, EndPage: 9},
38+
}},
39+
}
40+
raw, err := json.Marshal(toc)
41+
if err != nil {
42+
t.Fatalf("marshal toc: %v", err)
43+
}
44+
return raw
45+
}
46+
47+
// depsWithTOC builds a minimal Deps for buildTreeWalkCitations: no LLM
48+
// (so quote extraction is skipped) and a stub TOC provider on the
49+
// strategy.
50+
func depsWithTOC(toc []byte, tocErr error) Deps {
51+
return Deps{
52+
Logger: slog.Default(),
53+
TreeWalkStrategy: &retrieval.TreeWalkStrategy{
54+
TOC: stubTOCProvider{raw: toc, err: tocErr},
55+
},
56+
}
57+
}
58+
59+
// TestBuildTreeWalkCitations_EmitsHeadingPath is the HAL-70 regression:
60+
// a citation must carry the canonical heading path of its primary
61+
// section, resolved from the TOC — the field the bench's
62+
// path_correct@1 metric reads.
63+
func TestBuildTreeWalkCitations_EmitsHeadingPath(t *testing.T) {
64+
d := depsWithTOC(citationTestTOC(t), nil)
65+
tr := buildTreeWalkTestTree()
66+
res := &retrieval.Result{CitedPages: [][2]int{{1, 2}}}
67+
68+
cites := d.buildTreeWalkCitations(context.Background(), tr, res, "how do I install?", "")
69+
if len(cites) != 1 {
70+
t.Fatalf("want 1 citation, got %d: %v", len(cites), cites)
71+
}
72+
c := cites[0]
73+
74+
// Primary section for pages 1-2 is the leaf sec_a1 (Install).
75+
ids, _ := c["section_ids"].([]tree.SectionID)
76+
if len(ids) == 0 || ids[0] != "sec_a1" {
77+
t.Fatalf("expected primary section sec_a1, got %v", c["section_ids"])
78+
}
79+
80+
got, ok := c["title_path"].([]string)
81+
if !ok {
82+
t.Fatalf("citation is missing a title_path: %#v", c)
83+
}
84+
if want := []string{"Setup", "Install"}; !reflect.DeepEqual(got, want) {
85+
t.Fatalf("title_path mismatch: got=%v want=%v", got, want)
86+
}
87+
}
88+
89+
// TestBuildTreeWalkCitations_NoTOCOmitsHeadingPath: when no TOC is
90+
// persisted (ErrNoTOC), the citation degrades gracefully — section_ids
91+
// and pages are still present, title_path is simply absent.
92+
func TestBuildTreeWalkCitations_NoTOCOmitsHeadingPath(t *testing.T) {
93+
d := depsWithTOC(nil, retrieval.ErrNoTOC)
94+
tr := buildTreeWalkTestTree()
95+
res := &retrieval.Result{CitedPages: [][2]int{{5, 7}}}
96+
97+
cites := d.buildTreeWalkCitations(context.Background(), tr, res, "how do I query?", "")
98+
if len(cites) != 1 {
99+
t.Fatalf("want 1 citation, got %d", len(cites))
100+
}
101+
if _, present := cites[0]["title_path"]; present {
102+
t.Fatalf("title_path must be absent without a TOC, got %v", cites[0]["title_path"])
103+
}
104+
if _, present := cites[0]["section_ids"]; !present {
105+
t.Fatalf("section_ids must still be present")
106+
}
107+
}

0 commit comments

Comments
 (0)