finemargins / ibm_layer /docling_parser.py
ashish-doing's picture
deploy: full sync to HF
1977937
Raw
History Blame Contribute Delete
9.15 kB
"""
ibm_layer/docling_parser.py
Downloads the IFAB Laws of the Game 2025/26 PDF, parses Laws 9, 11, 12,
14 with Docling, and writes PARAPHRASED summaries to law_chunks.json.
COPYRIGHT WARNING: this script must never write verbatim IFAB text into
law_chunks.json. Docling extraction gives you exact source text; the
paraphrasing step below is a deliberate, separate transformation step,
not optional cleanup.
SCHEMA WARNING: I don't have your real contracts.py OfficiatingScenario
definition. The law_chunks.json schema below is my best guess based on
what tools.py's get_law_text() needs (law_number, title, summary).
Cross-check against contracts.py and officiating_scenarios.json's
existing 2 seeded scenarios before finalizing field names.
Run this as: python ibm_layer/docling_parser.py
"""
from __future__ import annotations
import json
from pathlib import Path
try:
from docling.document_converter import DocumentConverter
except ImportError as e:
raise ImportError(
"Could not import DocumentConverter from docling.document_converter. "
"This matches docling's documented API as of recent 2.x releases, "
"but if docling==2.103.0 has moved this, run "
"`python -c \"import docling; help(docling)\"` locally and fix this "
"import path."
) from e
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DOCS_DIR = PROJECT_ROOT / "data" / "docs"
PDF_PATH = DOCS_DIR / "IFAB-Laws-of-the-Game-2025_26.pdf"
PDF_URL = "https://assets.the-afc.com/downloads/referees/IFAB-Laws-of-the-Game-2025_26.pdf"
LAW_CHUNKS_OUTPUT = Path(__file__).resolve().parent / "law_chunks.json"
# The four laws in scope per the project brief. Titles are taken from the
# publicly known IFAB Laws of the Game numbering and should be
# cross-checked against the actual downloaded PDF's table of contents,
# since IFAB occasionally renumbers or retitles laws between editions.
LAWS_TO_PARSE = {
9: "The Ball",
11: "Offside",
12: "Fouls and Misconduct",
14: "The Penalty Kick",
}
def download_pdf() -> Path:
"""Download the Laws of the Game PDF if not already present locally."""
DOCS_DIR.mkdir(parents=True, exist_ok=True)
if PDF_PATH.exists():
print(f"PDF already present at {PDF_PATH}, skipping download.")
return PDF_PATH
import urllib.request
print(f"Downloading {PDF_URL} ...")
try:
req = urllib.request.Request(
PDF_URL,
headers={"User-Agent": "Mozilla/5.0 (compatible; FineMarginsBot/1.0)"},
)
with urllib.request.urlopen(req, timeout=60) as response:
data = response.read()
PDF_PATH.write_bytes(data)
print(f"Saved to {PDF_PATH} ({len(data) / 1024:.0f} KB)")
except Exception as e:
raise RuntimeError(
f"Failed to download PDF from {PDF_URL}. This may be a network "
f"issue, a changed URL (the-afc.com restructures asset paths "
f"periodically), or a User-Agent block. If this URL is dead, "
f"search 'IFAB Laws of the Game 2025/26 PDF' for IFAB's own "
f"hosted copy at theifab.com as a fallback source. "
f"Original error: {e}"
) from e
return PDF_PATH
def extract_full_text(pdf_path: Path) -> str:
"""Run Docling's DocumentConverter over the PDF and return plain text
(markdown export) of the whole document. Law-specific slicing happens
in a separate step since Docling doesn't know IFAB's law numbering."""
converter = DocumentConverter()
result = converter.convert(str(pdf_path))
# export_to_markdown() is Docling's documented text-extraction API;
# confirm this method name still exists in your installed version —
# if it errors, check result.document's available export_to_* methods.
return result.document.export_to_markdown()
def slice_law_sections(full_text: str) -> dict[int, str]:
"""
Naive section slicer: finds each "Law N" heading in the extracted
markdown and grabs the text up to the next "Law N+1" heading.
This is a starting heuristic, not guaranteed robust — Docling's
markdown output structure depends on how well it parses the PDF's
actual layout (multi-column rules documents can confuse text
extraction order). After running this once, manually inspect the
sliced sections against the real PDF before trusting them, especially
for Law 11 (Offside) and Law 12 (Fouls and Misconduct), which are
long and have many subsections.
"""
import re
sections: dict[int, str] = {}
law_numbers = sorted(LAWS_TO_PARSE.keys())
for law_num in law_numbers:
pattern = rf"(?:^|\n)#{{1,3}}\s*Law\s+{law_num}\b"
match = re.search(pattern, full_text, re.IGNORECASE)
if not match:
print(
f"WARNING: could not find a 'Law {law_num}' heading in the "
f"extracted text. Docling's markdown structure may differ "
f"from this regex's assumptions — inspect full_text "
f"manually (e.g. dump it to a .md file) and adjust the "
f"pattern in slice_law_sections()."
)
continue
start = match.start()
next_law_candidates = [
re.search(rf"(?:^|\n)#{{1,3}}\s*Law\s+{n}\b", full_text[start + 10:], re.IGNORECASE)
for n in range(law_num + 1, 18)
]
next_matches = [m for m in next_law_candidates if m]
if next_matches:
end = start + 10 + min(m.start() for m in next_matches)
else:
end = len(full_text)
sections[law_num] = full_text[start:end].strip()
return sections
def paraphrase_section(law_num: int, title: str, raw_text: str) -> str:
"""
Produce a PARAPHRASED summary of a law section — never verbatim.
PLACEHOLDER IMPLEMENTATION: this currently does basic bookkeeping,
which is NOT the same as paraphrasing and is NOT safe to ship as-is.
You have two real options once you run this:
Option A (manual, fastest for 4 laws): print raw_text for each law,
read it, and hand-write a 3-5 sentence paraphrase per law into
law_chunks.json directly. For exactly 4 laws this is the most
reliable path before a deadline.
Option B (automated via Granite once live): once granite_client.py
is confirmed working against your real watsonx.ai account, replace
this function's body with a call like:
from ibm_layer.granite_client import get_granite_response # adjust
prompt = (
f"Paraphrase the following football law text in your own "
f"words, in 3-5 sentences, preserving all rule meaning but "
f"using no more than 3 consecutive words matching the "
f"original phrasing at a time. Do not quote directly.\n\n"
f"{raw_text[:3000]}"
)
return get_granite_response(prompt)
This function currently returns a flagged stub so nothing gets
silently written to law_chunks.json that isn't actually safe to
display.
"""
return (
f"[UNPARAPHRASED PLACEHOLDER -- DO NOT SHIP] "
f"Raw extracted text for Law {law_num} ({title}) is "
f"{len(raw_text)} characters. Replace this with a real paraphrase "
f"via Option A or B described in paraphrase_section()'s docstring "
f"before this goes into the demo."
)
def build_law_chunks() -> list[dict]:
pdf_path = download_pdf()
full_text = extract_full_text(pdf_path)
raw_dump_path = DOCS_DIR / "extracted_full_text.md"
raw_dump_path.write_text(full_text, encoding="utf-8")
print(f"Full extracted text dumped to {raw_dump_path} for manual review.")
sections = slice_law_sections(full_text)
chunks = []
for law_num, title in LAWS_TO_PARSE.items():
raw_text = sections.get(law_num, "")
if not raw_text:
print(f"Skipping Law {law_num} -- no text extracted, see warning above.")
continue
chunks.append(
{
"law_number": law_num,
"title": title,
"summary": paraphrase_section(law_num, title, raw_text),
"source": "IFAB Laws of the Game 2025/26",
"source_url": PDF_URL,
}
)
return chunks
def main():
chunks = build_law_chunks()
if not chunks:
print(
"No law chunks were produced. Check the warnings above -- most "
"likely the heading regex in slice_law_sections() doesn't "
"match this PDF's actual structure. Inspect "
"data/docs/extracted_full_text.md directly."
)
return
LAW_CHUNKS_OUTPUT.write_text(
json.dumps(chunks, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f"\nWrote {len(chunks)} law chunk(s) to {LAW_CHUNKS_OUTPUT}")
print(
"\nREMINDER: summaries are currently PLACEHOLDER stubs, not "
"real paraphrases. See paraphrase_section()'s docstring for the "
"two ways to finish this before the demo."
)
if __name__ == "__main__":
main()