Skip to content

Commit 24672bd

Browse files
authored
Merge pull request #12 from hallelx2/fix/parser-bold-headings
parser: detect bold-as-heading + collapse letter-spacing (fixes filing parse)
2 parents 15940d3 + 5052ecb commit 24672bd

6 files changed

Lines changed: 382 additions & 35 deletions

File tree

pkg/ingest/ingest.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -359,11 +359,11 @@ func (p *Pipeline) summaryFor(ctx context.Context, s db.Section, childLines []st
359359
resp, err := p.LLM.Complete(ctx, llmgate.Request{
360360
Model: p.SummaryModel,
361361
Temperature: 0.0,
362-
MaxTokens: 200,
362+
MaxTokens: 260,
363363
Messages: []llmgate.Message{
364364
{Role: llmgate.RoleSystem, Content: summarySystemPrompt(profile)},
365365
{Role: llmgate.RoleUser, Content: fmt.Sprintf(
366-
"Summarize this section titled %q in a single sentence (max 40 words):\n\n%s",
366+
"Section titled %q.\n\n%s\n\nReturn a single sentence (≤ 60 words) that names this section's concrete topics, entities, identifiers, and key items so a retrieval engine can match it to user questions.",
367367
cleanForLLM(s.Title), body)},
368368
},
369369
})
@@ -484,16 +484,21 @@ func isLikelyMojibakeTitle(s string) bool {
484484
}
485485

486486
// summarySystemPrompt returns a domain-aware system prompt for the
487-
// summarization LLM based on the document's store profile. Domain framing
488-
// nudges the model toward the salient facts of that document class.
487+
// summarization LLM based on the document's store profile. Summaries are
488+
// optimized for RETRIEVAL: a downstream retrieval engine, given only the
489+
// summary, should be able to tell whether the section answers a specific
490+
// question. So we ask the model to name the concrete topics, entities,
491+
// identifiers, and key items the section covers — not just describe it
492+
// generically.
489493
func summarySystemPrompt(profile string) string {
494+
const retrievalRule = "Write so a downstream retrieval engine, reading only your summary, can tell whether this section answers a specific user question. Name the section's concrete topics — entities, identifiers, table contents, named items, key numbers — not just a generic description. One factual sentence, ≤ 60 words, no preamble, no quotes."
490495
switch strings.ToLower(strings.TrimSpace(profile)) {
491496
case "research":
492-
return "You summarize sections of academic research papers. In one factual sentence capture the key claim, method, dataset, or result of the section. No preamble, no quotes, no citations."
497+
return "You summarize sections of academic research papers. Capture the key claim, method, dataset, or result. " + retrievalRule
493498
case "medical":
494-
return "You summarize sections of clinical and medical documents. In one factual sentence capture the key finding, recommendation, dosage, definition, or guideline of the section. No preamble, no quotes."
499+
return "You summarize sections of clinical and medical documents. Capture the key finding, recommendation, dosage, drug name, definition, or guideline. " + retrievalRule
495500
default:
496-
return "You write short, factual section summaries. One sentence, no preamble, no quotes."
501+
return "You summarize sections of business, legal, and financial documents (filings, reports, contracts). " + retrievalRule
497502
}
498503
}
499504

pkg/parser/chunk_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package parser
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestChunkOversizedLeavesSplits(t *testing.T) {
9+
// 12 words per "sentence", 5 sentences ~ 60-65 words, ~360 chars; we want
10+
// >2400 chars so build it from a longer paragraph + a colon-terminated header.
11+
header := "Securities registered pursuant to Section 12(b) of the Act: "
12+
long := strings.Repeat("alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu ", 60)
13+
content := header + long
14+
if len(content) <= leafChunkThreshold {
15+
t.Fatalf("test setup: content must exceed threshold; got %d", len(content))
16+
}
17+
in := []Section{{Level: 1, Title: "3M COMPANY", Content: content}}
18+
19+
out := chunkOversizedLeaves(in)
20+
if len(out) != 1 {
21+
t.Fatalf("expected 1 top-level section, got %d", len(out))
22+
}
23+
parent := out[0]
24+
if parent.Title != "3M COMPANY" {
25+
t.Errorf("parent title should be preserved, got %q", parent.Title)
26+
}
27+
if parent.Content != "" {
28+
t.Errorf("parent content should be cleared after splitting, got %d chars", len(parent.Content))
29+
}
30+
if len(parent.Children) < 2 {
31+
t.Fatalf("expected multiple chunks, got %d", len(parent.Children))
32+
}
33+
// First chunk's title should use the colon-terminated header.
34+
if !strings.HasPrefix(parent.Children[0].Title, "Securities registered pursuant to Section 12(b)") {
35+
t.Errorf("first chunk title should come from the colon header, got %q", parent.Children[0].Title)
36+
}
37+
// Every chunk's content should be non-empty and well below the original.
38+
for i, c := range parent.Children {
39+
if c.Content == "" {
40+
t.Errorf("chunk %d has empty content", i)
41+
}
42+
if len(c.Content) > leafChunkTarget*2 {
43+
t.Errorf("chunk %d larger than expected: %d chars", i, len(c.Content))
44+
}
45+
}
46+
}
47+
48+
func TestChunkOversizedLeavesLeavesSmallSectionsAlone(t *testing.T) {
49+
in := []Section{
50+
{Level: 1, Title: "Intro", Content: strings.Repeat("a b c d e f ", 50)}, // ~600 chars
51+
{Level: 1, Title: "Methods", Content: strings.Repeat("x y z ", 200)}, // ~1200 chars
52+
}
53+
out := chunkOversizedLeaves(in)
54+
if len(out) != 2 {
55+
t.Fatalf("expected 2 sections preserved, got %d", len(out))
56+
}
57+
for i, s := range out {
58+
if len(s.Children) != 0 {
59+
t.Errorf("section %d was unexpectedly split into %d children", i, len(s.Children))
60+
}
61+
}
62+
}
63+
64+
func TestChunkOversizedLeavesRecursesIntoInternals(t *testing.T) {
65+
bigLeaf := Section{Level: 2, Title: "Detail", Content: strings.Repeat("the quick brown fox jumps over the lazy dog ", 100)}
66+
parent := Section{Level: 1, Title: "Parent", Children: []Section{bigLeaf}}
67+
out := chunkOversizedLeaves([]Section{parent})
68+
if len(out) != 1 || len(out[0].Children) == 0 {
69+
t.Fatalf("parent should be retained with chunked children, got %+v", out)
70+
}
71+
leaf := out[0].Children[0]
72+
if leaf.Title != "Detail" {
73+
t.Errorf("inner leaf title should be preserved, got %q", leaf.Title)
74+
}
75+
if len(leaf.Children) < 2 {
76+
t.Errorf("inner leaf should have been chunked, has %d children", len(leaf.Children))
77+
}
78+
}
79+
80+
func TestDeriveChunkTitleColonHeader(t *testing.T) {
81+
got := deriveChunkTitle("Securities registered pursuant to Section 12(b) of the Act: Title of each class ...", "fallback")
82+
want := "Securities registered pursuant to Section 12(b) of the Act"
83+
if got != want {
84+
t.Errorf("colon-header title: got %q want %q", got, want)
85+
}
86+
}
87+
88+
func TestDeriveChunkTitleFallback(t *testing.T) {
89+
if got := deriveChunkTitle("", "fb"); got != "fb" {
90+
t.Errorf("empty chunk should fall back, got %q", got)
91+
}
92+
}

0 commit comments

Comments
 (0)