Skip to content

Commit afb01b0

Browse files
waleedlatif1claude
andcommitted
fix(parsers): decode HTML by detected encoding and add before/after benchmark
Wires decodeTextBuffer into the HTML parser, refreshes the degraded docblock now that legacy formats raise typed errors, and adds the large corpus harness plus the regression-gated comparer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 8181eb5 commit afb01b0

5 files changed

Lines changed: 170 additions & 9 deletions

File tree

‎apps/sim/lib/file-parsers/html-parser.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getErrorMessage } from '@sim/utils/errors'
44
import * as cheerio from 'cheerio'
55
import { FileParserError } from '@/lib/file-parsers/errors'
66
import type { FileParseResult, FileParser } from '@/lib/file-parsers/types'
7-
import { sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
7+
import { decodeTextBuffer, sanitizeTextForUTF8 } from '@/lib/file-parsers/utils'
88

99
const logger = createLogger('HtmlParser')
1010

@@ -388,7 +388,8 @@ export class HtmlParser implements FileParser {
388388
try {
389389
logger.info('Parsing HTML buffer, size:', buffer.length)
390390

391-
const htmlContent = buffer.toString('utf-8')
391+
const decoded = decodeTextBuffer(buffer)
392+
const htmlContent = decoded.text
392393
const $ = cheerio.load(htmlContent)
393394

394395
// Extract meta information before removing tags
@@ -421,6 +422,8 @@ export class HtmlParser implements FileParser {
421422
links: links.slice(0, 50),
422423
hasImages: $('img').length > 0,
423424
imageCount: $('img').length,
425+
encoding: decoded.encoding,
426+
warning: decoded.warning,
424427
hasTable: $('table').length > 0,
425428
tableCount: $('table').length,
426429
hasList: $('ul, ol').length > 0,

‎apps/sim/lib/file-parsers/types.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ export interface FileParseMetadata {
77
* True when no real extraction happened and `content` is best-effort scraped
88
* bytes or a placeholder message rather than the document's text.
99
*
10-
* The legacy `pptx` fallback deliberately never throws, so an interactive
11-
* upload still shows the user something. An automated caller must not index
12-
* that: it embeds ZIP internals or an English placeholder sentence as if it
13-
* were document content. Such callers check this flag and skip the file.
10+
* Set by extractors that can only return best-effort output, such as a
11+
* spreadsheet whose cells are all blank. Legacy `.doc` and `.ppt` inputs used
12+
* to fall through to a byte scrape reported this way; they now raise typed
13+
* errors instead. An automated caller must not index degraded content, and
14+
* every automated consumer checks this flag and skips the file.
1415
*/
1516
degraded?: boolean
1617
extractionMethod?: string
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Compares two bench-run output directories file by file and applies a strict regression gate.
2+
Usage: bench-compare.py <bench-dir> <before-out> <after-out> [report.md]
3+
"""
4+
import json, os, re, statistics, sys, unicodedata
5+
from collections import Counter, defaultdict
6+
from rapidfuzz import fuzz
7+
8+
BENCH, BEFORE, AFTER = sys.argv[1:4]
9+
REPORT = sys.argv[4] if len(sys.argv) > 4 else os.path.join(BENCH, 'compare.md')
10+
REF = os.path.join(BENCH, 'reference')
11+
WORD = re.compile(r'\w+', re.UNICODE)
12+
NEEDLE_CAP, HAY_CAP, LINE_CAP = 160, 400_000, 300
13+
PAGE_NUM = re.compile(r'^(page\s*)?\d{1,4}(\s*(of|/)\s*\d{1,4})?$', re.I)
14+
# Extensions whose ok->error flips are intended: legacy .ppt now refuses with a typed error.
15+
INTENDED_ERROR_EXTS = {'ppt'}
16+
17+
def norm(t):
18+
t = unicodedata.normalize('NFKC', t or '').replace('­', '').replace('‑', '-').lower()
19+
return re.sub(r'\s+', ' ', t).strip()
20+
def vocab(t): return {w for w in WORD.findall(norm(t)) if len(w) >= 2}
21+
def found(needle, hay):
22+
n = norm(needle)[:NEEDLE_CAP]
23+
if not n: return False
24+
if n in hay: return True
25+
return fuzz.partial_ratio(n, hay[:HAY_CAP], score_cutoff=90) >= 90
26+
def blocks(t): return [l.strip() for l in (t or '').split('\n') if l.strip()]
27+
def junk_per_1k(t):
28+
bad = sum(1 for ch in t if (unicodedata.category(ch) in ('Cc', 'Co', 'Cn') and ch not in '\n\t\r') or ch in '�­')
29+
return round(1000 * bad / max(1, len(t)), 2)
30+
def r(x): return None if x is None else round(x, 4)
31+
32+
def load_ref(label):
33+
p = os.path.join(REF, label.split('__', 1)[1] + '.json')
34+
if not os.path.exists(p): return {}
35+
j = json.load(open(p))
36+
return {k: v for k, v in j.items() if isinstance(v, str) and k != 'error' and v.strip()}
37+
38+
def metrics(rec, refs):
39+
if not rec['ok']:
40+
return dict(ok=False, typed=bool(rec.get('typedError')), code=rec.get('errorCode') or rec.get('errorName'), error=rec.get('error', '')[:120], degraded=False)
41+
out = rec['content']; n_out = norm(out)
42+
m = dict(ok=True, typed=None, degraded=bool(rec['metadata'].get('degraded')), truncated=bool(rec['metadata'].get('truncated')), length=len(out), lines=len(blocks(out)), chunks=rec.get('chunkCount'), ms=rec['ms'], junk=junk_per_1k(out), detected=rec['metadata'].get('detectedType'), method=rec['metadata'].get('extractionMethod'), encoding=rec['metadata'].get('encoding'))
43+
line_counts = Counter(norm(l) for l in blocks(out) if 0 < len(l) <= 120)
44+
m['repeated_lines'] = sum(c - 1 for c in line_counts.values() if c >= 3)
45+
m['page_number_lines'] = sum(1 for l in blocks(out) if PAGE_NUM.match(l))
46+
ref_vocab = set(); recalls = []; precisions = []
47+
for name, text in refs.items():
48+
n_ref = norm(text); ref_vocab |= vocab(text)
49+
ref_lines = [l for l in blocks(text) if len(l) >= 25][:LINE_CAP]
50+
out_lines = [l for l in blocks(out) if len(l) >= 25][:LINE_CAP]
51+
if ref_lines: recalls.append(sum(found(l, n_out) for l in ref_lines) / len(ref_lines))
52+
if out_lines: precisions.append(sum(found(l, n_ref) for l in out_lines) / len(out_lines))
53+
m['recall'] = r(max(recalls)) if recalls else None
54+
m['precision'] = r(max(precisions)) if precisions else None
55+
if ref_vocab:
56+
words = [w for w in WORD.findall(n_out) if len(w) >= 2]
57+
noise = [w for w in words if w not in ref_vocab]
58+
m['noise'] = r(len(noise) / max(1, len(words)))
59+
m['glued'] = len({w for w in set(noise) if len(w) >= 6 and any(w[:i] in ref_vocab and w[i:] in ref_vocab and i >= 2 and len(w) - i >= 2 for i in range(2, len(w) - 1))})
60+
m['ref_vocab_recall'] = r(len(set(words) & ref_vocab) / max(1, len(ref_vocab)))
61+
return m
62+
63+
rows = []
64+
labels = sorted(set(os.listdir(BEFORE)) & set(os.listdir(AFTER)))
65+
for f in labels:
66+
if f.startswith('_'): continue
67+
b = json.load(open(os.path.join(BEFORE, f))); a = json.load(open(os.path.join(AFTER, f)))
68+
refs = load_ref(b['label'])
69+
mb, ma = metrics(b, refs), metrics(a, refs)
70+
flags = []
71+
if mb['ok'] and not ma['ok']:
72+
if b['ext'] in INTENDED_ERROR_EXTS or (mb.get('degraded') and ma.get('typed')): flags.append('intended:ok->typed-error')
73+
elif ma.get('typed'): flags.append('REGRESSION:ok->error')
74+
else: flags.append('REGRESSION:ok->untyped-error')
75+
if not mb['ok'] and ma['ok']: flags.append('improved:error->ok')
76+
if not mb['ok'] and not ma['ok'] and not mb.get('typed') and ma.get('typed'): flags.append('improved:untyped->typed')
77+
if mb['ok'] and ma['ok']:
78+
if mb.get('recall') is not None and ma.get('recall') is not None and ma['recall'] < mb['recall'] - 0.02: flags.append(f"REGRESSION:recall {mb['recall']}->{ma['recall']}")
79+
if mb.get('ref_vocab_recall') is not None and ma.get('ref_vocab_recall') is not None and ma['ref_vocab_recall'] < mb['ref_vocab_recall'] - 0.02: flags.append(f"REGRESSION:vocab-recall {mb['ref_vocab_recall']}->{ma['ref_vocab_recall']}")
80+
if mb.get('noise') is not None and ma.get('noise') is not None and ma['noise'] > mb['noise'] + 0.02: flags.append(f"REGRESSION:noise {mb['noise']}->{ma['noise']}")
81+
if mb.get('glued') is not None and ma.get('glued', 0) > mb.get('glued', 0): flags.append(f"REGRESSION:glued {mb['glued']}->{ma['glued']}")
82+
if ma['junk'] > mb['junk'] + 0.5: flags.append(f"REGRESSION:junk {mb['junk']}->{ma['junk']}")
83+
if not mb['degraded'] and ma['degraded']: flags.append('REGRESSION:now-degraded')
84+
if ma['ms'] > 3 * mb['ms'] and ma['ms'] - mb['ms'] > 500: flags.append(f"SLOWER:{mb['ms']:.0f}->{ma['ms']:.0f}ms")
85+
if ma['length'] < 0.8 * mb['length'] and mb['length'] > 200 and (mb.get('recall') is None or ma.get('recall') is None): flags.append(f"CHECK:length {mb['length']}->{ma['length']} (no reference)")
86+
rows.append(dict(label=b['label'], ext=b['ext'], before=mb, after=ma, flags=flags))
87+
88+
json.dump(rows, open(os.path.join(BENCH, 'compare.json'), 'w'), indent=1)
89+
90+
def mean(xs):
91+
xs = [x for x in xs if isinstance(x, (int, float)) and not isinstance(x, bool)]
92+
return f'{statistics.mean(xs):.3f}' if xs else '—'
93+
by_ext = defaultdict(list)
94+
for row in rows: by_ext[row['ext']].append(row)
95+
cols = ['recall', 'ref_vocab_recall', 'precision', 'noise', 'glued', 'lines', 'repeated_lines', 'page_number_lines', 'junk', 'chunks', 'ms']
96+
L = ['# Before/after benchmark', '', f'{len(rows)} files compared. Gate: recall −0.02, vocab recall −0.02, noise +0.02, glued +1, junk +0.5, ok→error (except intended), now-degraded.', '']
97+
regressions = [x for x in rows if any(f.startswith('REGRESSION') for f in x['flags'])]
98+
L += [f"**Regressions: {len(regressions)}**", '']
99+
L += ['| ext | n | ok before→after | typed errors b→a | degraded b→a | ' + ' | '.join(f'{c} b→a' for c in cols) + ' |', '|' + '---|' * (len(cols) + 5)]
100+
for ext in sorted(by_ext):
101+
rs = by_ext[ext]; bs = [x['before'] for x in rs]; as_ = [x['after'] for x in rs]
102+
cells = [f"{mean([b.get(c) for b in bs])}→{mean([a.get(c) for a in as_])}" for c in cols]
103+
L.append(f"| {ext} | {len(rs)} | {sum(b['ok'] for b in bs)}→{sum(a['ok'] for a in as_)} | {sum(1 for b in bs if not b['ok'] and b.get('typed'))}→{sum(1 for a in as_ if not a['ok'] and a.get('typed'))} | {sum(1 for b in bs if b.get('degraded'))}→{sum(1 for a in as_ if a.get('degraded'))} | " + ' | '.join(cells) + ' |')
104+
L += ['', '## Flags per file', '', '| file | flags |', '|---|---|']
105+
for row in rows:
106+
if row['flags']: L.append(f"| {row['label']} | {'; '.join(row['flags'])} |")
107+
open(REPORT, 'w').write('\n'.join(L) + '\n')
108+
print('\n'.join(L[:8 + len(by_ext)]))
109+
print(f"\nregressions={len(regressions)} flagged={sum(1 for x in rows if x['flags'])} report={REPORT}")
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Runs every file under `<bench-dir>/files/<ext>/` through the production
3+
* `parseBuffer` path and writes one JSON record per file into `<out-dir>`.
4+
* Run from apps/sim of the checkout under test:
5+
* `DATABASE_URL=postgres://x:y@localhost:1/none bun scripts/parser-eval/bench-run.ts <bench-dir> <out-dir>`
6+
*/
7+
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs'
8+
import path from 'path'
9+
import { TextChunker } from '@/lib/chunkers/text-chunker'
10+
import { parseBuffer } from '@/lib/file-parsers'
11+
import { FileParserError } from '@/lib/file-parsers/errors'
12+
13+
const BENCH = process.argv[2]
14+
const OUT = process.argv[3]
15+
mkdirSync(OUT, { recursive: true })
16+
const chunker = new TextChunker({ chunkSize: 1024, chunkOverlap: 200, minCharactersPerChunk: 100 })
17+
const filesRoot = path.join(BENCH, 'files')
18+
const summary: Record<string, { ok: number; error: number; typed: number }> = {}
19+
20+
for (const ext of readdirSync(filesRoot).sort()) {
21+
const dir = path.join(filesRoot, ext)
22+
if (!statSync(dir).isDirectory()) continue
23+
summary[ext] = { ok: 0, error: 0, typed: 0 }
24+
for (const file of readdirSync(dir).sort()) {
25+
const label = `${ext}__${file}`
26+
const bytes = readFileSync(path.join(dir, file))
27+
const started = performance.now()
28+
try {
29+
const result = await parseBuffer(bytes, ext, { pdfTextMode: ext === 'pdf' ? 'complete' : undefined })
30+
const ms = performance.now() - started
31+
let chunkCount = -1
32+
try { chunkCount = (await chunker.chunk(result.content)).length } catch {}
33+
const { html, sampledData, messages, headings, links, ...metadata } = result.metadata ?? {}
34+
writeFileSync(path.join(OUT, `${label}.json`), JSON.stringify({ label, ext, file, bytes: bytes.length, ms, ok: true, content: result.content, metadata, chunkCount }))
35+
summary[ext].ok++
36+
process.stdout.write(`ok ${label} ${result.content.length}ch ${ms.toFixed(0)}ms${metadata.degraded ? ' DEGRADED' : ''}${metadata.truncated ? ' TRUNCATED' : ''}\n`)
37+
} catch (error) {
38+
const ms = performance.now() - started
39+
const typed = error instanceof FileParserError
40+
writeFileSync(path.join(OUT, `${label}.json`), JSON.stringify({ label, ext, file, bytes: bytes.length, ms, ok: false, typedError: typed, errorCode: typed ? error.code : undefined, errorName: (error as Error)?.name, error: String((error as Error)?.message ?? error).slice(0, 300) }))
41+
summary[ext].error++
42+
if (typed) summary[ext].typed++
43+
process.stdout.write(`FAIL ${label} ${typed ? `typed:${error.code}` : `UNTYPED:${(error as Error)?.name}`} ${String((error as Error)?.message).slice(0, 80)}\n`)
44+
}
45+
}
46+
}
47+
writeFileSync(path.join(OUT, '_summary.json'), JSON.stringify(summary, null, 1))
48+
console.log(JSON.stringify(summary))

‎apps/sim/scripts/parser-eval/score.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,16 +110,16 @@ def score_tier_b(rec):
110110
'truncated-docx': ('typed error (invalid_format)', lambda rec: not rec['ok'] and rec.get('typedError')),
111111
'truncated-pdf': ('typed error (invalid_format)', lambda rec: not rec['ok'] and rec.get('typedError')),
112112
'pdf-bytes-labelled-docx': ('typed error OR correct text', lambda rec: (not rec['ok'] and rec.get('typedError')) or (rec['ok'] and 'Office Relocation' in rec['content'])),
113-
'docx-bytes-labelled-pdf': ('typed error', lambda rec: not rec['ok'] and rec.get('typedError')),
113+
'docx-bytes-labelled-pdf': ('typed error OR correct text (magic wins)', lambda rec: (not rec['ok'] and rec.get('typedError')) or (rec['ok'] and 'Office Relocation' in rec['content'])),
114114
'png-labelled-doc': ('typed error, never placeholder prose', lambda rec: not rec['ok'] and rec.get('typedError')),
115115
'random-bytes-labelled-ppt': ('typed error, never placeholder prose', lambda rec: not rec['ok'] and rec.get('typedError')),
116116
'latin1-txt': ('text decodes to "Café résumé naïve £"', lambda rec: rec['ok'] and 'Café' in rec['content'] and '£' in rec['content']),
117117
'utf16-txt': ('text decodes to "Hello UTF-16 world"', lambda rec: rec['ok'] and 'Hello UTF-16 world' in rec['content']),
118118
'html-labelled-txt': ('markup stripped or typed error', lambda rec: (not rec['ok'] and rec.get('typedError')) or (rec['ok'] and '<html' not in rec['content'].lower())),
119-
'docx-labelled-xlsx': ('typed error', lambda rec: not rec['ok'] and rec.get('typedError')),
119+
'docx-labelled-xlsx': ('typed error OR correct text (magic wins)', lambda rec: (not rec['ok'] and rec.get('typedError')) or (rec['ok'] and 'Office Relocation' in rec['content'])),
120120
'csv-labelled-xlsx': ('typed error OR correct UTF-8 text', lambda rec: (not rec['ok'] and rec.get('typedError')) or (rec['ok'] and 'Araújo' in rec['content'] and 'Ã' not in rec['content'])),
121121
'docx-labelled-doc': ('correct text', lambda rec: rec['ok'] and 'Office Relocation' in rec['content'] and not rec['metadata'].get('degraded')),
122-
'pptx-labelled-ppt': ('correct text', lambda rec: rec['ok'] and 'Office Relocation' in rec['content'] and not rec['metadata'].get('degraded')),
122+
'pptx-labelled-ppt': ('typed unsupported_type (.ppt refused) OR correct text', lambda rec: (not rec['ok'] and rec.get('errorCode') == 'unsupported_type') or (rec['ok'] and 'Office Relocation' in rec['content'] and not rec['metadata'].get('degraded'))),
123123
}
124124

125125
scores = {'A': {}, 'B': {}, 'R': {}}

0 commit comments

Comments
 (0)