156 lines
6.3 KiB
Python
156 lines
6.3 KiB
Python
"""Generate a separate synthetic QA corpus; never replace the original import benchmark."""
|
|
import argparse
|
|
from hashlib import sha256
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
|
|
from docx import Document
|
|
from docx.oxml import OxmlElement
|
|
from docx.oxml.ns import qn
|
|
from reportlab import __file__ as reportlab_file
|
|
from reportlab.pdfbase import pdfmetrics
|
|
from reportlab.pdfbase.ttfonts import TTFont
|
|
from reportlab.pdfgen import canvas
|
|
|
|
|
|
FONT = "CvQaSynthetic"
|
|
FONT_PATH = Path(reportlab_file).parent / "fonts/Vera.ttf"
|
|
|
|
|
|
def load_corpus(path):
|
|
cases = json.loads(path.read_text(encoding="utf-8"))
|
|
ids = [case["id"] for case in cases]
|
|
if len(ids) != len(set(ids)):
|
|
raise ValueError("Duplicate logical fixture ID.")
|
|
for case in cases:
|
|
if not re.fullmatch(r"[a-z0-9-]+", case["id"]) or case["split"] not in {"development", "frozen"}:
|
|
raise ValueError("Invalid fixture ID or split.")
|
|
if not isinstance(case["expected"], dict) or not case["blocks"]:
|
|
raise ValueError("Every case requires independent expectations and source blocks.")
|
|
return cases
|
|
|
|
|
|
def fragments(case):
|
|
"""Source units for literal text recovery, before interpretation or field assignment."""
|
|
result = []
|
|
for block in case["blocks"]:
|
|
if block.get("type") == "table":
|
|
result.extend(block["headers"])
|
|
result.extend(cell for row in block["rows"] for cell in row if cell)
|
|
elif block.get("type") != "page_break":
|
|
result.append(block["text"])
|
|
return result
|
|
|
|
|
|
def generate_case(case, output):
|
|
identifier = case["id"]
|
|
if not re.fullmatch(r"[a-z0-9-]+", identifier):
|
|
raise ValueError("Unsafe fixture ID.")
|
|
paths = {kind: output / f"{identifier}.{kind}" for kind in ("pdf", "docx")}
|
|
if any(path.exists() for path in paths.values()):
|
|
raise FileExistsError("Select fresh fixture output; historical bytes are immutable.")
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
pdfmetrics.registerFont(TTFont(FONT, str(FONT_PATH)))
|
|
pdf = canvas.Canvas(str(paths["pdf"]), pagesize=(595, 842), invariant=1)
|
|
pdf.setTitle("Synthetic CV integration QA: " + identifier)
|
|
pdf.setAuthor("Jobjakt synthetic fixture")
|
|
word = Document()
|
|
y = 800
|
|
|
|
def page():
|
|
nonlocal y
|
|
pdf.showPage()
|
|
y = 800
|
|
|
|
def wrap(text, width, size=10):
|
|
lines, line = [], ""
|
|
for token in text.split():
|
|
proposed = (line + " " + token).strip()
|
|
if line and pdfmetrics.stringWidth(proposed, FONT, size) > width:
|
|
lines.append(line)
|
|
line = token
|
|
else:
|
|
line = proposed
|
|
return lines + [line]
|
|
|
|
for block in case["blocks"]:
|
|
kind = block.get("type", "paragraph")
|
|
if kind == "page_break":
|
|
word.add_page_break()
|
|
page()
|
|
continue
|
|
if kind == "table":
|
|
headers, rows = block["headers"], block["rows"]
|
|
if not headers or any(len(row) != len(headers) for row in rows):
|
|
raise ValueError("Table rows must match actual header columns.")
|
|
table = word.add_table(rows=1, cols=len(headers))
|
|
table.style = "Table Grid"
|
|
for cell, text in zip(table.rows[0].cells, headers):
|
|
cell.text = text
|
|
repeat_header = OxmlElement("w:tblHeader")
|
|
table.rows[0]._tr.get_or_add_trPr().append(repeat_header)
|
|
for row in rows:
|
|
for cell, text in zip(table.add_row().cells, row):
|
|
cell.text = text
|
|
width = 515 / len(headers)
|
|
for row in [headers] + rows:
|
|
wrapped = [wrap(cell, width - 10) for cell in row]
|
|
height = max(len(lines) for lines in wrapped) * 14 + 10
|
|
if y - height < 40:
|
|
page()
|
|
pdf.setFont(FONT, 10)
|
|
for column, lines in enumerate(wrapped):
|
|
x = 40 + column * width
|
|
pdf.rect(x, y - height, width, height)
|
|
for i, line in enumerate(lines):
|
|
pdf.drawString(x + 5, y - 15 - 14 * i, line)
|
|
y -= height
|
|
y -= 18
|
|
continue
|
|
if kind not in {"paragraph", "heading", "list"}:
|
|
raise ValueError("Unsupported fixture block kind.")
|
|
text = block["text"]
|
|
paragraph = word.add_paragraph(text)
|
|
if kind == "heading":
|
|
paragraph.style = word.styles["Heading 2"]
|
|
elif kind == "list":
|
|
paragraph.style = word.styles["List Bullet"]
|
|
# PDF lists use a visible explicit marker; DOCX lists use actual paragraph list styling.
|
|
size = 12 if kind == "heading" else 10
|
|
lines = wrap(("- " if kind == "list" else "") + text, 515, size)
|
|
height = len(lines) * 15 + (14 if kind == "heading" else 9)
|
|
if y - height < 40:
|
|
page()
|
|
pdf.setFont(FONT, size)
|
|
for line in lines:
|
|
pdf.drawString(40, y, line)
|
|
y -= 15
|
|
y -= 14 if kind == "heading" else 9
|
|
pdf.save()
|
|
word.save(paths["docx"])
|
|
return paths
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--corpus", type=Path, default=Path(__file__).with_name("corpus.json"))
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
if args.output.exists():
|
|
raise FileExistsError("Select a fresh directory; do not overwrite a frozen manifest.")
|
|
cases = load_corpus(args.corpus)
|
|
manifest = {"corpusSha256": sha256(args.corpus.read_bytes()).hexdigest(), "logicalCases": len(cases),
|
|
"generatorSha256": sha256(Path(__file__).read_bytes()).hexdigest(), "documents": []}
|
|
for case in cases:
|
|
for kind, path in generate_case(case, args.output).items():
|
|
manifest["documents"].append({"caseId": case["id"], "split": case["split"], "format": kind,
|
|
"file": path.name, "sha256": sha256(path.read_bytes()).hexdigest(),
|
|
"bytes": path.stat().st_size})
|
|
(args.output / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
print(json.dumps({"logicalCases": len(cases), "documents": len(manifest["documents"]), "output": str(args.output)}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|