Skip to content

Commit 95c4e18

Browse files
committed
fix(ingest): keep the filename when the parsed title is mojibake
Two-layer PDFs (real text + an overlay watermark drawn at the same Y coordinate) produce parsed titles like "GGlloobbaall SSttrraatteeggyy" because chars from both layers interleave in row order. persistTree was writing that straight to documents.title, replacing the friendly filename that the upload handler had seeded. isLikelyMojibakeTitle catches the doubled-glyph signature (>30% adjacent same-letter pairs) plus suspiciously short titles (<4 alphanumeric chars). When it fires we skip SetDocumentTitle, so the row keeps its filename instead. Unit test covers real titles, filename-style strings, the actual GINA mojibake we observed, and a few edge cases.
1 parent 2d43c94 commit 95c4e18

2 files changed

Lines changed: 80 additions & 1 deletion

File tree

pkg/ingest/ingest.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,12 @@ func (p *Pipeline) parse(ctx context.Context, pl Payload) (*parser.ParsedDoc, er
141141
// persistTree writes sections + full content in document order. Parents
142142
// are written before children so the FK on sections.parent_id holds.
143143
func (p *Pipeline) persistTree(ctx context.Context, docID tree.DocumentID, doc *parser.ParsedDoc) error {
144-
if doc.Title != "" {
144+
// Only overwrite the row's title (which was seeded with the
145+
// filename at upload time) when the parsed title looks usable.
146+
// Watermarked PDFs whose overlay text shares a Y coordinate with
147+
// the real title produce mojibake like "GGlloobbaall SSttrraatteeggyy"
148+
// — we'd rather keep the original filename than show that to a user.
149+
if doc.Title != "" && !isLikelyMojibakeTitle(doc.Title) {
145150
if err := p.DB.SetDocumentTitle(ctx, docID, doc.Title); err != nil {
146151
return err
147152
}
@@ -365,6 +370,42 @@ func (p *Pipeline) fail(ctx context.Context, id tree.DocumentID, stage string, c
365370
}
366371
}
367372

373+
// isLikelyMojibakeTitle returns true when s shows the doubled-glyph
374+
// signature of two-layer PDFs (an overlay watermark drawn over real
375+
// text at the same Y coordinate, so chars from both layers interleave
376+
// into runs like "GGlloobbaall"). Also flags suspiciously short titles
377+
// that are pure punctuation/whitespace.
378+
//
379+
// Conservative on purpose: we'd rather show a slightly weird real
380+
// title than silently fall back to the filename for a normal doc.
381+
func isLikelyMojibakeTitle(s string) bool {
382+
s = strings.TrimSpace(s)
383+
if s == "" {
384+
return true
385+
}
386+
// Count alphabetic chars + adjacent same-letter pairs (case-insensitive).
387+
letters := 0
388+
doubled := 0
389+
var prev rune
390+
for _, r := range strings.ToLower(s) {
391+
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
392+
letters++
393+
if r == prev {
394+
doubled++
395+
}
396+
prev = r
397+
} else {
398+
prev = 0
399+
}
400+
}
401+
if letters < 4 {
402+
return true // too few letters to be a real title
403+
}
404+
// >30% adjacent-doubled letters is the signature of the two-layer
405+
// glyph interleaving — normal English titles sit well under 5%.
406+
return doubled*100/letters > 30
407+
}
408+
368409
// approxTokens is a cheap 4-chars-per-token heuristic used at ingest
369410
// time so we don't spend a provider round-trip per section just to
370411
// populate metadata. Real token counts are reconciled when a retrieval

pkg/ingest/mojibake_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package ingest
2+
3+
import "testing"
4+
5+
func TestIsLikelyMojibakeTitle(t *testing.T) {
6+
cases := []struct {
7+
in string
8+
mojiBad bool
9+
}{
10+
// Real titles that must NOT be flagged.
11+
{"Global Strategy for Asthma Management and Prevention", false},
12+
{"Attention Is All You Need", false},
13+
{"The Pragmatic Programmer", false},
14+
{"Q1 2026 Financial Report", false},
15+
{"Annual Review 2025", false},
16+
{"a", true}, // too few letters
17+
{"PDF", true},
18+
{"GINA-2025-Update-25_11_08-WMS.pdf", false}, // filenames are valid
19+
{"book", false}, // exactly 4 letters
20+
// The GINA watermark interleaving cases we actually saw.
21+
{"GGlloobbaall SSttrraatteeggyy ffoorr", true},
22+
{"AAsstthhmmaa MMaannaaggeemmeenntt", true},
23+
{"aanndd PPrreevveennttiioonn", true},
24+
// Edge cases.
25+
{"", true},
26+
{" ", true},
27+
{"Hello", false},
28+
{"HHHHHHHHH", true}, // pure repetition
29+
// Some abbreviation-heavy titles (common in academic papers).
30+
{"BERT: Pre-training of Deep Bidirectional Transformers", false},
31+
}
32+
for _, tc := range cases {
33+
got := isLikelyMojibakeTitle(tc.in)
34+
if got != tc.mojiBad {
35+
t.Errorf("isLikelyMojibakeTitle(%q) = %v, want %v", tc.in, got, tc.mojiBad)
36+
}
37+
}
38+
}

0 commit comments

Comments
 (0)