Skip to content

Commit db6888d

Browse files
authored
Sanitize document titles + drop repeated icon-glyph marker rows (#56)
* @ Sanitize document titles + drop repeated icon-glyph marker rows Title sanitize: an ingest title with invalid UTF-8 bytes (e.g. a client that mangled an em-dash in the multipart field) made the Postgres insert fail with "db write failed" and 500 the whole upload. sanitizeTitle now coerces the title to valid UTF-8 and strips control chars before the DB write; the parsed-title path is likewise run through cleanForLLM. A bad title byte can no longer fail an ingest. Cover-page furniture: isRepeatedMarkerLine drops rows that are mostly the same short token repeated — the artifact of an icon glyph rendered as text and stamped once per item (e.g. the ORCID "iD" logo on a paper byline, "iD iD iD iD"), which the font heuristic otherwise surfaced as junk author-block sections. Real prose never repeats an identical <=2-char token as >=60% of a >=3-token line, so this is safe. @ * gofmt test files
1 parent 8630d0c commit db6888d

5 files changed

Lines changed: 123 additions & 3 deletions

File tree

internal/handler/documents.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import (
77
"io"
88
"log/slog"
99
"net/http"
10+
"regexp"
1011
"strconv"
1112
"strings"
1213
"time"
14+
"unicode/utf8"
1315

1416
"github.com/go-chi/chi/v5"
1517

@@ -128,6 +130,34 @@ func (h *DocumentsHandler) HandleListDocuments(w http.ResponseWriter, r *http.Re
128130
writeJSON(w, http.StatusOK, resp)
129131
}
130132

133+
// sanitizeTitle coerces a document title into a value safe for the
134+
// Postgres text column: valid UTF-8, no C0 control chars (except none —
135+
// titles are single-line), whitespace-collapsed and trimmed. Invalid
136+
// byte sequences (a client that mangled a non-ASCII char in the multipart
137+
// field) are dropped rather than replaced so the title stays clean. An
138+
// all-garbage title collapses to "" and the caller falls back to the doc id.
139+
func sanitizeTitle(s string) string {
140+
if !utf8.ValidString(s) {
141+
s = strings.ToValidUTF8(s, "")
142+
}
143+
var b strings.Builder
144+
b.Grow(len(s))
145+
for _, r := range s {
146+
if r == utf8.RuneError {
147+
continue
148+
}
149+
if r < 0x20 { // drop control chars incl. tab/newline — titles are one line
150+
b.WriteRune(' ')
151+
continue
152+
}
153+
b.WriteRune(r)
154+
}
155+
return strings.TrimSpace(multiSpaceRe.ReplaceAllString(b.String(), " "))
156+
}
157+
158+
// multiSpaceRe collapses runs of whitespace in a sanitized title.
159+
var multiSpaceRe = regexp.MustCompile(`\s{2,}`)
160+
131161
// sourceTypeFromContentType collapses an HTTP Content-Type to the
132162
// short tag the dashboard's source-type badge expects.
133163
func sourceTypeFromContentType(ct string) string {
@@ -249,6 +279,12 @@ func (h *DocumentsHandler) HandleIngestDocument(w http.ResponseWriter, r *http.R
249279
if title == "" {
250280
title = filename
251281
}
282+
// Sanitize before the DB write: a title with invalid UTF-8 bytes
283+
// (e.g. a client that mangled an em-dash) would otherwise make the
284+
// Postgres insert fail with "db write failed" and 500 the whole
285+
// ingest. Coerce to valid UTF-8 and drop control chars so a bad
286+
// title byte can never fail the upload.
287+
title = sanitizeTitle(title)
252288
if title == "" {
253289
title = string(docID)
254290
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package handler
2+
3+
import "testing"
4+
5+
func TestSanitizeTitle(t *testing.T) {
6+
cases := []struct{ in, want string }{
7+
{"Attention Is All You Need", "Attention Is All You Need"},
8+
{" spaced out title ", "spaced out title"},
9+
{"Berkshire — 2023", "Berkshire — 2023"}, // valid UTF-8 em-dash is kept
10+
{"bad\xff\xfebyte", "badbyte"}, // invalid UTF-8 bytes dropped
11+
{"line\nbreak\ttab", "line break tab"}, // control chars → space, collapsed
12+
{"\xff\xfe", ""}, // all-garbage collapses to empty
13+
{"", ""},
14+
}
15+
for _, c := range cases {
16+
if got := sanitizeTitle(c.in); got != c.want {
17+
t.Errorf("sanitizeTitle(%q) = %q, want %q", c.in, got, c.want)
18+
}
19+
}
20+
}

pkg/ingest/ingest.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,7 +714,9 @@ func (p *Pipeline) persistTree(ctx context.Context, store docPersister, docID tr
714714
// Watermarked PDFs whose overlay text shares a Y coordinate with
715715
// the real title produce mojibake like "GGlloobbaall SSttrraatteeggyy"
716716
// — we'd rather keep the original filename than show that to a user.
717-
if err := store.SetDocumentTitle(ctx, docID, doc.Title); err != nil {
717+
// cleanForLLM strips any invalid-UTF-8 bytes so the title write can
718+
// never fail the persist.
719+
if err := store.SetDocumentTitle(ctx, docID, cleanForLLM(doc.Title)); err != nil {
718720
return err
719721
}
720722
}

pkg/parser/pdf.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1477,12 +1477,42 @@ var boilerplateFragments = []string{
14771477
"or scholarly",
14781478
}
14791479

1480+
// isRepeatedMarkerLine reports whether a row is a run of the same short
1481+
// token repeated — the artifact of an icon glyph rendered as text and
1482+
// stamped once per item, e.g. the ORCID "iD" logo repeated once per author
1483+
// on a paper's byline ("iD iD iD iD"). Real prose never repeats an
1484+
// identical ≤2-character token as the majority of a ≥3-token line, so
1485+
// dropping these is safe and removes the junk author-block "headings"
1486+
// (e.g. PRISMA's contributor page) the font heuristic would otherwise
1487+
// surface as sections.
1488+
func isRepeatedMarkerLine(s string) bool {
1489+
fields := strings.Fields(s)
1490+
if len(fields) < 3 {
1491+
return false
1492+
}
1493+
counts := map[string]int{}
1494+
for _, f := range fields {
1495+
if len([]rune(f)) <= 2 {
1496+
counts[f]++
1497+
}
1498+
}
1499+
for _, c := range counts {
1500+
if c*100 >= len(fields)*60 { // ≥60% of the line is one short token
1501+
return true
1502+
}
1503+
}
1504+
return false
1505+
}
1506+
14801507
// isBoilerplateLine reports whether a row is publisher/license noise.
14811508
// Matches the curated signature list, the bare arXiv id stamp
1482-
// ("arXiv:2401.01234v2 [cs.CL] 5 Jan 2024"), and short license-tail
1483-
// fragments.
1509+
// ("arXiv:2401.01234v2 [cs.CL] 5 Jan 2024"), short license-tail
1510+
// fragments, and repeated icon-glyph marker rows (ORCID "iD iD iD").
14841511
func isBoilerplateLine(s string) bool {
14851512
low := strings.ToLower(strings.TrimSpace(s))
1513+
if isRepeatedMarkerLine(low) {
1514+
return true
1515+
}
14861516
for _, sig := range boilerplateSignatures {
14871517
if strings.Contains(low, sig) {
14881518
return true

pkg/parser/repeated_marker_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package parser
2+
3+
import "testing"
4+
5+
// TestIsRepeatedMarkerLine locks the filter that drops icon-glyph marker
6+
// rows (e.g. an ORCID "iD" repeated once per author) while never touching
7+
// real prose or short headings.
8+
func TestIsRepeatedMarkerLine(t *testing.T) {
9+
drop := []string{
10+
"id id id id", // 4/4 = 100%
11+
"iD iD iD iD", // case-insensitive caller lower-cases first; test raw too
12+
"id id id", // 3/3
13+
"id id id id author", // 4/5 = 80% ≥ 60%
14+
}
15+
keep := []string{
16+
"the right to erasure is established in article 17",
17+
"multi-head attention", // 2 tokens, below the 3-token floor
18+
"introduction", // single token
19+
"id id id penny whiting17, david moher 22", // 3 of 9 ≈ 33% → real author line, kept
20+
"id id elie a. akl 8, sue e. brennan", // 2 of 8 → kept
21+
}
22+
for _, s := range drop {
23+
if !isRepeatedMarkerLine(s) {
24+
t.Errorf("expected DROP for %q", s)
25+
}
26+
}
27+
for _, s := range keep {
28+
if isRepeatedMarkerLine(s) {
29+
t.Errorf("expected KEEP for %q", s)
30+
}
31+
}
32+
}

0 commit comments

Comments
 (0)