Spaces:
Sleeping
Sleeping
File size: 8,565 Bytes
62f3c0d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 | """Parse SP 21 PDF into structured per-standard records.
The PDF is laid out so that each standard is bounded by a 'SUMMARY OF' header
followed by an IS code line. We scan the full text page-by-page, segment by
those headers, and extract metadata + body for each standard.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, asdict
from pathlib import Path
import fitz
IS_CODE_RE = re.compile(
r"IS\s+(\d+)\s*(\(Part\s*[IVX0-9]+\))?\s*:\s*(\d{4})",
re.IGNORECASE,
)
SUMMARY_HDR_RE = re.compile(r"SUMMARY\s+OF\s*\n\s*(IS\s+[^\n]+)", re.IGNORECASE)
REVISION_TAIL_RE = re.compile(r"\((First|Second|Third|Fourth|Fifth)\s+Revision\)\s*$", re.IGNORECASE)
@dataclass
class Standard:
is_code: str # canonical: "IS 3466: 1988" or "IS 2185 (Part 2): 1983"
is_code_norm: str # normalized: "is3466:1988" — matches eval_script normalization
title: str # e.g. "MASONRY CEMENT"
revision: str | None # e.g. "Second Revision" or None
page_start: int # 1-indexed
page_end: int
scope: str # extracted "Scope" body if found
full_text: str # entire body text (post header)
def normalize_is_code(s: str) -> str:
return re.sub(r"\s+", "", s).lower()
def canonicalize_is_code(part_num: str | None, number: str, year: str) -> str:
"""Canonical form mirrors the public_test_set.json style: 'IS 2185 (Part 2): 1983'."""
if part_num:
# Normalize whitespace and title-case the word 'Part'
part_clean = re.sub(r"\s+", " ", part_num.strip())
part_clean = re.sub(r"^\(\s*part\s*", "(Part ", part_clean, flags=re.IGNORECASE)
return f"IS {number} {part_clean}: {year}"
return f"IS {number}: {year}"
def extract_standards(pdf_path: Path) -> list[Standard]:
"""Walk every page, detect 'SUMMARY OF\\nIS ...' anchors, slice the doc accordingly."""
doc = fitz.open(pdf_path)
# Build a list of (page_idx, page_text). Append page markers so we can map back.
pages_text: list[str] = []
for page in doc:
pages_text.append(page.get_text("text"))
doc.close()
# Find anchors per page
anchors: list[tuple[int, str, str, str | None, str, str, str | None]] = []
# (page_idx_0based, raw_header_line, is_number, part, year, title, revision)
for page_idx, text in enumerate(pages_text):
for m in SUMMARY_HDR_RE.finditer(text):
header_line = m.group(1).strip()
code_match = IS_CODE_RE.search(header_line)
if not code_match:
continue
number = code_match.group(1)
part = code_match.group(2)
year = code_match.group(3)
# Title = everything in header_line after the IS code.
# The "(X Revision)" tag may appear inline OR on one of the next
# 1–3 lines (some entries break the title onto multiple lines).
after_code = header_line[code_match.end():].strip()
tail_after_anchor = text[m.end():m.end() + 400]
tail_lines = [ln.strip() for ln in tail_after_anchor.lstrip().split("\n") if ln.strip()]
# If the title is empty after the IS-code line, take the next
# non-empty line as the title (some standards put it there).
if not after_code and tail_lines:
after_code = tail_lines[0]
tail_lines = tail_lines[1:]
# Search for a revision marker in the title or in any of the next
# ~3 short lines (gives multi-line titles a chance).
revision = None
rev_match = REVISION_TAIL_RE.search(after_code)
if rev_match:
revision = rev_match.group(0).strip("() ")
after_code = REVISION_TAIL_RE.sub("", after_code).strip()
else:
_REV_LINE_RE = re.compile(
r"^\(?\s*(First|Second|Third|Fourth|Fifth|Sixth|Seventh|Eighth|Ninth|Tenth)"
r"\s+Revision\s*\)?\s*$",
re.IGNORECASE,
)
for cand in tail_lines[:3]:
if _REV_LINE_RE.match(cand):
revision = re.sub(r"[()]", "", cand).strip()
break
title = after_code.strip()
anchors.append((page_idx, header_line, number, part, year, title, revision))
# Build standards by slicing pages_text from anchor i to anchor i+1
standards: list[Standard] = []
for i, (page_idx, header_line, number, part, year, title, revision) in enumerate(anchors):
start_page = page_idx
end_page = (anchors[i + 1][0] - 1) if i + 1 < len(anchors) else len(pages_text) - 1
# If the next anchor is on the same page, the standard ends on this page
if i + 1 < len(anchors) and anchors[i + 1][0] == page_idx:
end_page = page_idx
body_chunks: list[str] = []
for p in range(start_page, end_page + 1):
body_chunks.append(pages_text[p])
full_text = "\n".join(body_chunks).strip()
# Extract Scope: text after "Scope" / "1. Scope" until next numbered heading
scope = _extract_scope(full_text)
canonical = canonicalize_is_code(part, number, year)
standards.append(Standard(
is_code=canonical,
is_code_norm=normalize_is_code(canonical),
title=title,
revision=revision,
page_start=start_page + 1,
page_end=end_page + 1,
scope=scope,
full_text=full_text,
))
return standards
# Note: NO re.IGNORECASE here — we rely on uppercase-letter classes acting as
# case-sensitive markers. The lookahead terminates on next numbered section
# (e.g. "2. ..."), an uppercase-only heading line, or "SUMMARY OF" (start of
# next standard).
_SCOPE_RE = re.compile(
r"(?:^|\n)\s*1[\.\s]+(?:Scope|SCOPE)[\s\-\u2014\u2013:]*"
r"([\s\S]+?)"
r"(?=\n\s*(?:\d+\.\s|[A-Z][A-Z][A-Z]+\b|SUMMARY\s+OF))"
)
def _extract_scope(full_text: str) -> str:
m = _SCOPE_RE.search(full_text)
if not m:
return ""
scope = m.group(1).strip()
scope = re.sub(r"\s+", " ", scope)
return scope[:1500]
def main():
import os as _os
pdf_path = Path("datasets/dataset.pdf")
out_standards = Path("data/parsed_standards.json")
out_whitelist = Path("data/is_code_whitelist.json")
# Idempotency: skip parse if both outputs already exist (they're committed
# in the repo so a fresh `git clone` already has them). Override with
# PARSER_FORCE=1.
if (
not _os.getenv("PARSER_FORCE")
and out_standards.exists()
and out_whitelist.exists()
and out_standards.stat().st_size > 1024
and out_whitelist.stat().st_size > 100
):
print(f"Parsed standards already exist at {out_standards} — skipping parse.")
print("(Set PARSER_FORCE=1 to re-parse.)")
return
standards = extract_standards(pdf_path)
print(f"Extracted {len(standards)} standards from {pdf_path}")
# Save full structured output
out_standards.parent.mkdir(parents=True, exist_ok=True)
with out_standards.open("w", encoding="utf-8") as f:
json.dump([asdict(s) for s in standards], f, ensure_ascii=False, indent=2)
print(f" -> {out_standards}")
# Save whitelist (canonical + normalized) for hallucination guard
whitelist = {
"canonical": sorted({s.is_code for s in standards}),
"normalized": sorted({s.is_code_norm for s in standards}),
}
with out_whitelist.open("w", encoding="utf-8") as f:
json.dump(whitelist, f, ensure_ascii=False, indent=2)
print(f" -> {out_whitelist} ({len(whitelist['canonical'])} unique IS codes)")
# Print a quick sanity check vs public test set expectations
print("\nSanity check vs public_test_set.json expected_standards:")
with open("datasets/public_test_set.json", encoding="utf-8") as f:
pub = json.load(f)
norm_set = set(whitelist["normalized"])
miss = []
for q in pub:
for exp in q["expected_standards"]:
n = normalize_is_code(exp)
mark = "OK " if n in norm_set else "MISS"
print(f" {mark} {exp}")
if n not in norm_set:
miss.append(exp)
if miss:
print(f"\nMissing {len(miss)}/10 expected codes — investigate parser.")
else:
print("\nAll 10 expected codes present in whitelist.")
if __name__ == "__main__":
main()
|