diff --git a/docs/implementation/media/nephrology-fidelity-chart.png b/docs/implementation/media/nephrology-fidelity-chart.png new file mode 100644 index 0000000..34a1e13 Binary files /dev/null and b/docs/implementation/media/nephrology-fidelity-chart.png differ diff --git a/docs/implementation/media/nephrology-fidelity-grouping.png b/docs/implementation/media/nephrology-fidelity-grouping.png new file mode 100644 index 0000000..5781fb7 Binary files /dev/null and b/docs/implementation/media/nephrology-fidelity-grouping.png differ diff --git a/wikify/engine/__init__.py b/wikify/engine/__init__.py index bf14427..fc7a842 100644 --- a/wikify/engine/__init__.py +++ b/wikify/engine/__init__.py @@ -73,6 +73,7 @@ def parse_pdf( with fitz.open(pdf_path) as doc: total = doc.page_count + repeated = images.repeated_images(doc) sd = store.create_document( title=title, import_name=import_name, @@ -85,11 +86,11 @@ def parse_pdf( png = pdf_utils.render_png(page, dpi=dpi) kind = pdf_utils.classify_page(page, min_chars, min_drawings) markdown = baseline.parse_page(pdf_path, page_no) - # Rescue genuine graphics the baseline parser omits (diagrams/photos with no - # recoverable text) by embedding them inline; text-image placeholders are left - # for the cleanup pass. No-op on the common page with no genuine figure. - markdown = images.embed_genuine_images(page, markdown, sd, page_no, kind) page_name = store.add_page(sd, page_no, kind, png, markdown) + figures = images.page_figures(page, repeated, page_name, page_no) + if figures: + markdown = images.place_figures(markdown, figures, page_no) + store.set_baseline_markdown(page_name, markdown) # Visual pages MUST be judged (text GT is unreliable on diagrams); text # pages are judged only when the operator opted in (cost/latency). diff --git a/wikify/engine/generate.py b/wikify/engine/generate.py index fb5126d..70f36e7 100644 --- a/wikify/engine/generate.py +++ b/wikify/engine/generate.py @@ -32,6 +32,9 @@ from wikify.engine.refs import smallest_covering DATA_MAX_LENGTH = 140 +OVERVIEW_TITLE = "Overview" +CONTENTS_TITLE = "Contents" +PRINTED_CONTENTS_TITLES = {"contents", "table of contents"} def _upsert_wiki_document( @@ -92,6 +95,18 @@ def bounded_route(prefix: str, title: str, identifier: str) -> tuple[str, str]: return f"{prefix}/{slug}", slug +def overview_route(prefix: str, section_name: str) -> tuple[str, str]: + return bounded_route(prefix, OVERVIEW_TITLE, section_name) + + +def contents_route(prefix: str, source_document: str) -> tuple[str, str]: + return bounded_route(prefix, CONTENTS_TITLE, source_document) + + +def has_own_body(section: dict) -> bool: + return bool(section["is_group"] and (section["markdown"] or "").strip()) + + class _WikiGenerator: """Projects one approved Source Document tree into a Wiki Space (see module doc).""" @@ -113,6 +128,8 @@ def __init__( self.included = [s for s in self.sections if s["include_in_wiki"]] self.wiki_name: dict[str, str] = {} # section name → wiki document name self.wiki_route: dict[str, str] = {} # section name → wiki route + self.page_name: dict[str, str] = {} # section name → wiki document showing its content + self.page_route: dict[str, str] = {} self.content: dict[str, str] = {} # section name → content written self.deleted = 0 self.links = 0 @@ -124,6 +141,7 @@ def run(self) -> dict: self._stage("Building wiki pages") self._build_structure() self._rollup_empty_groups() + self._add_contents() self._stage("Resolving page references") self._rewrite_links() store.set_document_wiki(self.sd.name, self.space.name, self.root_group.name, status="Wiki-Generated") @@ -152,7 +170,26 @@ def _sweep_stale(self) -> None: rest under the root group deepest-first (NestedSet needs leaves gone first). """ kept = {self.root_group.name} + if self._needs_contents(): + kept |= set( + frappe.get_all( + "Wiki Document", + filters={"route": contents_route(self.root_group.route, self.sd.name)[0], "is_group": 0}, + pluck="name", + ) + ) kept |= {s["wiki_document"] for s in self.included if s["wiki_document"]} + overview_routes = [ + overview_route(self.root_group.route, s["name"])[0] for s in self.included if has_own_body(s) + ] + if overview_routes: + kept |= set( + frappe.get_all( + "Wiki Document", + filters={"route": ["in", overview_routes], "is_group": 0}, + pluck="name", + ) + ) descendants = get_descendants_of("Wiki Document", self.root_group.name, ignore_permissions=True) stale = ( frappe.get_all( @@ -189,7 +226,7 @@ def _content_for(section: dict) -> str: return "" if section["is_group"] else f"# {section['title']}\n" def _build_structure(self) -> None: - sort_counter: dict[str, int] = {} # parent wiki name → next sort_order + sort_counter: dict[str, int] = {self.root_group.name: 1 if self._needs_contents() else 0} total = len(self.included) for i, s in enumerate(self.included): parent_name = self._parent_for(s) @@ -200,7 +237,7 @@ def _build_structure(self) -> None: doc = _upsert_wiki_document( s["wiki_document"], title=s["title"], - content=content, + content="" if has_own_body(s) else content, is_group=bool(s["is_group"]), parent=parent_name, route=route, @@ -209,11 +246,34 @@ def _build_structure(self) -> None: ) self.wiki_name[s["name"]] = doc.name self.wiki_route[s["name"]] = doc.route + self.page_name[s["name"]] = doc.name + self.page_route[s["name"]] = doc.route self.content[s["name"]] = content + if has_own_body(s): + self._add_overview(s, doc) + sort_counter[doc.name] = 1 store.set_section_wiki_document(s["name"], doc.name) if self.progress_cb: self.progress_cb(i + 1, total) + def _add_overview(self, section: dict, group) -> None: + """The live wiki redirects a group to its first page, so a group's own text would + never be shown; it goes on an Overview page sorted first instead.""" + route, slug = overview_route(self.root_group.route, section["name"]) + existing = frappe.db.get_value("Wiki Document", {"route": route, "is_group": 0}, "name") + doc = _upsert_wiki_document( + existing, + title=OVERVIEW_TITLE, + content=self.content[section["name"]], + is_group=False, + parent=group.name, + route=route, + slug=slug, + sort_order=0, + ) + self.page_name[section["name"]] = doc.name + self.page_route[section["name"]] = doc.route + def _rollup_empty_groups(self) -> None: """Give container pages (groups with no own body) a Contents list linking to their direct children, so a section landing page renders links instead of a bare heading. @@ -236,6 +296,42 @@ def _rollup_empty_groups(self) -> None: "Wiki Document", self.wiki_name[s["name"]], "content", toc, update_modified=False ) + def _needs_contents(self) -> bool: + return bool(self.included) and not any( + s["title"].strip().lower() in PRINTED_CONTENTS_TITLES for s in self.included + ) + + def _add_contents(self) -> None: + """The PDF printed no contents pages, so the space gets one built from the section tree.""" + if not self._needs_contents(): + return + depth: dict[str, int] = {} + lines = [] + for s in self.included: + parent = self.by_name.get(s["parent_source_section"]) + while parent is not None and parent["name"] not in depth: + parent = self.by_name.get(parent["parent_source_section"]) + depth[s["name"]] = depth[parent["name"]] + 1 if parent else 0 + pages = ( + f"p. {s['page_start']}" + if s["page_end"] in (None, s["page_start"]) + else f"pp. {s['page_start']}-{s['page_end']}" + ) + lines.append( + f"{' ' * depth[s['name']]}- [{s['title']}](/{self.wiki_route[s['name']]}) — {pages}\n" + ) + route, slug = contents_route(self.root_group.route, self.sd.name) + doc = _upsert_wiki_document( + frappe.db.get_value("Wiki Document", {"route": route, "is_group": 0}, "name"), + title=CONTENTS_TITLE, + content=f"# {CONTENTS_TITLE}\n\n" + "".join(lines), + is_group=False, + parent=self.root_group.name, + route=route, + slug=slug, + ) + frappe.db.set_value("Wiki Document", doc.name, "sort_order", 0, update_modified=False) + def _route_for_page(self, n: int) -> str | None: """Smallest-span included section whose PDF page range contains n → its route.""" best = smallest_covering(self.included, n) @@ -249,11 +345,11 @@ def _rewrite_links(self) -> None: self.content[s["name"]], page_count, self._route_for_page, - current_route=self.wiki_route[s["name"]], + current_route=self.page_route[s["name"]], ) if links: frappe.db.set_value( - "Wiki Document", self.wiki_name[s["name"]], "content", new_md, update_modified=False + "Wiki Document", self.page_name[s["name"]], "content", new_md, update_modified=False ) self.links += links @@ -340,11 +436,16 @@ def route_for_page(n: int) -> str | None: best = smallest_covering(routed, n) return routes[best["name"]] if best else None - own_route = routes.get(section_name) + target, own_route = sec.wiki_document, routes.get(section_name) + if has_own_body(sec): + own_route = overview_route(own_route.rpartition("/")[0], section_name)[0] + target = frappe.db.get_value("Wiki Document", {"route": own_route, "is_group": 0}, "name") + if not target: + return {"synced": False, "reason": "needs_regenerate"} page_count = frappe.db.get_value("Source Document", sec.source_document, "page_count") content, links = rewrite_page_refs(content, page_count or 10**9, route_for_page, current_route=own_route) - frappe.db.set_value("Wiki Document", sec.wiki_document, "content", content, update_modified=False) + frappe.db.set_value("Wiki Document", target, "content", content, update_modified=False) return {"synced": True, "chars": len(content), "links": links, "route": own_route} diff --git a/wikify/engine/images.py b/wikify/engine/images.py index 49f0b9e..7e500e1 100644 --- a/wikify/engine/images.py +++ b/wikify/engine/images.py @@ -1,102 +1,365 @@ -"""Extract genuine embedded graphics from a PDF page and embed them inline. +from __future__ import annotations -pymupdf4llm omits every image as a `==> picture [W x H] intentionally omitted <==` -placeholder. For an *image of text* it also recovers the text (a "picture text" block, -which the cleanup pass turns into real Markdown), so those don't need the image. But a -genuine graphic (diagram, flowchart, photo, chart) has no recoverable text — the -placeholder is all that's left, and cleanup strips even that, silently losing the figure. +import re +import unicodedata +from collections import Counter +from dataclasses import dataclass -This replaces such genuine-graphic placeholders with an embedded image link to a private -Frappe File, so the figure survives into the section tree + wiki. Text-image placeholders -are left untouched for cleanup. Deterministic — no LLM, no key needed. -""" +import fitz -from __future__ import annotations +from wikify.engine import pdf_utils, regions, store + +FIGURE_DPI = 200 +CROP_PADDING = 4.0 +MERGE_GAP = 2.0 +MIN_FIGURE_SIDE = 72.0 +MIN_FIGURE_AREA = 0.02 +MAX_FIGURE_AREA = 0.85 +MIN_REPEATED_PAGES = 3 +REPEATED_PAGE_SHARE = 0.2 +CAPTION_GAP = 24.0 +MAX_CAPTION_CHARS = 120 +ANCHOR_LENGTHS = (60, 25) +MIN_ANCHOR_CHARS = 12 +EDGE_TOLERANCE = 2.0 +MAX_ECHO_CHARS = 120 +MIN_TEXT_LAYER_CHARS = 200 +_IMAGE_LINE_RE = re.compile(r"^\s*!\[[^\]]*\]\([^)]*\)\s*$") +_ECHO_BLOCKER_RE = re.compile(r"^\s*(?:[|]|```|[-*+]\s|\d+[.)]\s)") +_WORD_RE = re.compile(r"\w{3,}") +_TRANSCRIPTION_START_RE = re.compile(r"^\s*(?:\||]*>|&#?\w+;") +_FENCE_RE = re.compile(r"^[ \t]*```", re.MULTILINE) + + +@dataclass +class Figure: + bbox: tuple[float, float, float, float] + page_size: tuple[float, float] = (1.0, 1.0) + caption: str = "" + text_before: str = "" + text_after: str = "" + url: str = "" -import re -import fitz # PyMuPDF -from frappe.utils.file_manager import save_file - -# Below this pixel area an "image" is almost always a rule, divider, bullet, icon, or -# logo — not a figure worth embedding. -_MIN_AREA = 200_000 - -# A page whose extractable text is below this is "sparse" — when it also carries a big -# image, the content lives in the image (a flowchart/diagram pymupdf dropped), so embed it -# even without a placeholder. Text-dense pages never trip this, so we don't re-embed page -# scans whose text the parser already recovered. -_SPARSE_TEXT = 700 - -_OMIT_RE = re.compile(r"\*{0,2}==>\s*picture \[(\d+) x (\d+)\] intentionally omitted <==\*{0,2}") -_PICTURE_TEXT = "Start of picture text" - - -def _is_text_image(markdown: str, match_end: int) -> bool: - """A "picture text" marker right after the placeholder means pymupdf recovered the - image's text — keep the text (cleanup handles it), don't embed the image.""" - return _PICTURE_TEXT in markdown[match_end : match_end + 200] - - -def _save_png(page, xref: int, source_document: str, page_no: int, idx: int) -> str | None: - """Extract one embedded image to PNG and host it as a private File on the document.""" - try: - pix = fitz.Pixmap(page.parent, xref) - if pix.n - pix.alpha >= 4: # CMYK / DeviceN → RGB so PNG encodes - pix = fitz.Pixmap(fitz.csRGB, pix) - png = pix.tobytes("png") - except Exception: - return None - file_doc = save_file( - f"page-{page_no:04d}-fig-{idx + 1}.png", - png, - "Source Document", - source_document, - is_private=1, +def repeated_images(pdf_document) -> set[bytes]: + pages_seen: Counter = Counter() + for page in pdf_document: + pages_seen.update({info["digest"] for info in page.get_image_info(hashes=True)}) + threshold = max(MIN_REPEATED_PAGES, pdf_document.page_count * REPEATED_PAGE_SHARE) + return {digest for digest, count in pages_seen.items() if count >= threshold} + + +def boxes_touch(box: tuple, other: tuple) -> bool: + return ( + box[0] - MERGE_GAP <= other[2] + and other[0] - MERGE_GAP <= box[2] + and box[1] - MERGE_GAP <= other[3] + and other[1] - MERGE_GAP <= box[3] ) - return file_doc.file_url - - -def _big_images(page) -> list[int]: - """Substantial embedded image xrefs, de-duplicated, in page order.""" - imgs, seen = [], set() - for im in page.get_images(full=True): - xref = im[0] - if xref not in seen and im[2] * im[3] >= _MIN_AREA: - seen.add(xref) - imgs.append(xref) - return imgs - - -def embed_genuine_images(page, markdown: str, source_document: str, page_no: int, kind: str = "text") -> str: - """Embed genuine graphics the baseline parser would otherwise drop. - - Two cases: - 1. An omitted-picture placeholder with no recovered text → replace it inline with the - matching page image (precise placement). - 2. No placeholder, but a sparse-text page carrying a big image → a flowchart/diagram - pymupdf dropped silently; append it as a figure. - Returns the markdown unchanged on the common page (text-dense, or no substantial image, - or only text-image placeholders). Visual pages are left to the VLM re-parse. - """ - imgs = _big_images(page) - if not imgs: - return markdown - # Case 1: genuine-graphic placeholders (omitted, no "picture text"), matched in order. - genuine = [m for m in _OMIT_RE.finditer(markdown) if not _is_text_image(markdown, m.end())] - if genuine: - out, cursor = [], 0 - for idx, m in enumerate(genuine): - out.append(markdown[cursor : m.start()]) - url = _save_png(page, imgs[idx], source_document, page_no, idx) if idx < len(imgs) else None - out.append(f"![Figure {page_no}.{idx + 1}]({url})" if url else m.group(0)) - cursor = m.end() - out.append(markdown[cursor:]) - return "".join(out) - - # Case 2: silently-dropped figure on a sparse text page (the content is in the image). - if kind == "visual" or "![" in markdown or len(page.get_text("text")) >= _SPARSE_TEXT: + +def merge_touching(boxes: list[tuple]) -> list[tuple]: + merged: list[tuple] = [] + for box in boxes: + while touching := next((other for other in merged if boxes_touch(box, other)), None): + merged.remove(touching) + box = ( + min(box[0], touching[0]), + min(box[1], touching[1]), + max(box[2], touching[2]), + max(box[3], touching[3]), + ) + merged.append(box) + return merged + + +def is_figure_sized(box: tuple, page_box: tuple) -> bool: + if box[2] - box[0] < MIN_FIGURE_SIDE or box[3] - box[1] < MIN_FIGURE_SIDE: + return False + return MIN_FIGURE_AREA <= regions.area_fraction(box, page_box) <= MAX_FIGURE_AREA + + +def body_text_blocks(page, page_box: tuple) -> list[tuple[tuple, str]]: + blocks = [] + for block in page.get_text("blocks"): + text = " ".join((block[4] or "").split()) + box = tuple(float(value) for value in block[:4]) + if block[6] == 0 and text and not regions.is_furniture(box, page_box): + blocks.append((box, text)) + return blocks + + +def overlaps_horizontally(box: tuple, other: tuple) -> bool: + return min(box[2], other[2]) > max(box[0], other[0]) + + +def nearest_block(candidates: list[tuple[tuple, str]], figure_box: tuple, key) -> str: + aligned = [block for block in candidates if overlaps_horizontally(block[0], figure_box)] + pool = aligned or candidates + return min(pool, key=key)[1] if pool else "" + + +def find_caption(blocks: list[tuple[tuple, str]], figure_box: tuple) -> str: + for box, text in blocks: + near_below = 0 <= box[1] - figure_box[3] <= CAPTION_GAP + near_above = 0 <= figure_box[1] - box[3] <= CAPTION_GAP + if ( + (near_below or near_above) + and overlaps_horizontally(box, figure_box) + and len(text) <= MAX_CAPTION_CHARS + and _CAPTION_RE.match(text) + ): + return text + return "" + + +def find_figures(page, repeated: set[bytes]) -> list[Figure]: + page_box = tuple(float(value) for value in page.rect) + boxes = [] + for info in page.get_image_info(hashes=True): + if info["digest"] in repeated: + continue + box = tuple(float(value) for value in (page.rect & info["bbox"])) + if regions.area(box) and not regions.is_furniture(box, page_box): + boxes.append(box) + figure_boxes = sorted( + (box for box in merge_touching(boxes) if is_figure_sized(box, page_box)), + key=lambda box: (box[1], box[0]), + ) + + blocks = body_text_blocks(page, page_box) + figures = [] + for figure_box in figure_boxes: + outside = [block for block in blocks if regions.overlap_fraction(block[0], [figure_box]) < 0.5] + above = [block for block in outside if block[0][3] <= figure_box[1] + EDGE_TOLERANCE] + below = [block for block in outside if block[0][1] >= figure_box[3] - EDGE_TOLERANCE] + figures.append( + Figure( + bbox=figure_box, + page_size=(page.rect.width, page.rect.height), + caption=find_caption(outside, figure_box), + text_before=nearest_block(above, figure_box, key=lambda block: -block[0][3]), + text_after=nearest_block(below, figure_box, key=lambda block: block[0][1]), + ) + ) + return figures + + +def text_line_boxes(page) -> list[tuple]: + return [ + tuple(float(value) for value in line["bbox"]) + for block in page.get_text("dict")["blocks"] + if block["type"] == 0 + for line in block["lines"] + if "".join(span["text"] for span in line["spans"]).strip() + ] + + +def figure_clip(page, figure: Figure) -> fitz.Rect: + """The padded figure box, moved off any text line its top or bottom edge would cut in half: a line + mostly inside the picture is taken in whole, a line outside it (a title above) is left out whole.""" + box = figure.bbox + top, bottom = box[1] - CROP_PADDING, box[3] + CROP_PADDING + for line in text_line_boxes(page): + if not overlaps_horizontally(line, box): + continue + inside = max(0.0, min(line[3], box[3]) - max(line[1], box[1])) / max(line[3] - line[1], 1.0) + if line[1] < top < line[3]: + top = line[1] if inside >= 0.5 else line[3] + if line[1] < bottom < line[3]: + bottom = line[3] if inside >= 0.5 else line[1] + return page.rect & fitz.Rect(box[0] - CROP_PADDING, top, box[2] + CROP_PADDING, bottom) + + +def crop_figure(page, figure: Figure) -> bytes: + return pdf_utils.render_png(page, dpi=FIGURE_DPI, clip=figure_clip(page, figure)) + + +def page_figures(page, repeated: set[bytes], page_name: str, page_no: int) -> list[Figure]: + figures = find_figures(page, repeated) + for figure in figures: + figure.url = store.save_crop_file(page_name, page_no, crop_figure(page, figure)).file_url + return figures + + +def figure_hint(figures: list[Figure]) -> str: + if not figures: + return "" + lines = [] + for number, figure in enumerate(figures, start=1): + width, height = figure.page_size + left, top, right, bottom = figure.bbox + down = f"{top / height:.0%} to {bottom / height:.0%} down the page" + across = f"{left / width:.0%} to {right / width:.0%} across" + lines.append(f"- Figure {number}: {down}, {across}") + positions = "\n".join(lines) + return ( + f"\n\nFIGURES: this page has {len(figures)} picture(s) that are cropped from the PDF and shown " + f"as images, numbered top to bottom:\n{positions}\n" + "Mark where each picture sits in the reading order with its token on a line of its own, " + "followed by the picture's printed title when it has one: [[FIGURE 1]] or " + "[[FIGURE 1: Admissions per year]]. Use every token exactly once and never write Markdown " + "image tags.\n" + "- A chart, graph, plot, photo, map or illustration is fully shown by its image: write only " + "its token. Never turn its bars, lines, axis ticks, legend or data labels into a table, a list " + "or numbers — the page does not print those values, so anything you write would be invented.\n" + "- A picture that is mostly text (a flowchart, form, notice, or a table printed as an image): " + "write its token, then transcribe its text below the token following the rules above.\n" + "- Text printed outside the pictures is transcribed as usual." + ) + + +def figure_tag(figure: Figure, number: int, page_no: int, title: str = "") -> str: + alt = " ".join((figure.caption or title or f"Figure {page_no}.{number}").split()) + return f"![{alt.replace('[', '(').replace(']', ')')}]({figure.url})" + + +def searchable(markdown: str) -> tuple[str, list[int]]: + skipped = [match.span() for match in _SKIPPED_MARKUP_RE.finditer(markdown)] + characters, positions = [], [] + span_index = 0 + for index, character in enumerate(markdown): + while span_index < len(skipped) and skipped[span_index][1] <= index: + span_index += 1 + if span_index < len(skipped) and skipped[span_index][0] <= index: + continue + if character.isalnum(): + characters.append(character.lower()) + positions.append(index) + return "".join(characters), positions + + +def normalized(text: str) -> str: + return "".join(character.lower() for character in text if character.isalnum()) + + +def layer_text(text: str) -> str: + return unicodedata.normalize("NFKC", text) + + +def is_echo(line: str, page_text: str, page_words: set[str]) -> bool: + """A short line the parser read off the picture itself: neither the line nor most of its words are + anywhere in the page's text layer, so it can only be a label or caption drawn inside the image.""" + text = line.strip() + if not text or len(text) > MAX_ECHO_CHARS or _ECHO_BLOCKER_RE.match(text): + return False + if normalized(layer_text(text)) in page_text: + return False + words = {word.lower() for word in _WORD_RE.findall(layer_text(text))} + return len(words & page_words) * 2 < len(words) or not words + + +def drop_echoes_after_figures(markdown: str, page_text: str) -> str: + page_words = {word.lower() for word in _WORD_RE.findall(layer_text(page_text))} + page_text = normalized(layer_text(page_text)) + lines = markdown.splitlines() + drop: set[int] = set() + for index, line in enumerate(lines): + if not _IMAGE_LINE_RE.match(line): + continue + echoes = [] + following = index + 1 + while following < len(lines) and ( + not lines[following].strip() or is_echo(lines[following], page_text, page_words) + ): + if lines[following].strip(): + echoes.append(following) + following += 1 + if following == len(lines) or not _TRANSCRIPTION_START_RE.match(lines[following]): + drop.update(echoes) + if not drop: return markdown - url = _save_png(page, imgs[0], source_document, page_no, 0) - return f"{markdown.rstrip()}\n\n![Figure {page_no}.1]({url})\n" if url else markdown + return re.sub(r"\n{3,}", "\n\n", "\n".join(line for index, line in enumerate(lines) if index not in drop)) + + +def drop_figure_echoes(pages: list[tuple[int, str]], pdf_path: str) -> list[tuple[int, str]]: + """Drop the labels and captions a parser copied out of a picture into the text after it. Only on pages + with a text layer to check against, since on a scanned page every line would look drawn.""" + with fitz.open(pdf_path) as pdf_document: + cleaned = [] + for page_no, markdown in pages: + page_text = pdf_document[page_no - 1].get_text() if 0 < page_no <= pdf_document.page_count else "" + if "![" in markdown and len(normalized(layer_text(page_text))) >= MIN_TEXT_LAYER_CHARS: + markdown = drop_echoes_after_figures(markdown, page_text) + cleaned.append((page_no, markdown)) + return cleaned + + +def enclosing_block(markdown: str, index: int) -> tuple[int, int] | None: + fences = [match.start() for match in _FENCE_RE.finditer(markdown)] + for opening, closing in zip(fences[::2], fences[1::2], strict=False): + if opening <= index <= closing: + line_end = markdown.find("\n", closing) + return opening, len(markdown) if line_end == -1 else line_end + table_start = markdown.rfind("", 0, index) < table_start: + table_end = markdown.find("", index) + if table_end != -1: + return table_start, table_end + len("") + return None + + +def line_bounds(markdown: str, index: int) -> tuple[int, int]: + start = markdown.rfind("\n", 0, index) + 1 + end = markdown.find("\n", index) + end = len(markdown) if end == -1 else end + block = enclosing_block(markdown, index) + if block: + return markdown.rfind("\n", 0, block[0]) + 1, block[1] + if markdown[start:end].lstrip().startswith("|"): + while start > 0 and markdown[markdown.rfind("\n", 0, start - 1) + 1 : start].lstrip().startswith("|"): + start = markdown.rfind("\n", 0, start - 1) + 1 + while end < len(markdown): + next_end = markdown.find("\n", end + 1) + next_end = len(markdown) if next_end == -1 else next_end + if not markdown[end + 1 : next_end].lstrip().startswith("|"): + break + end = next_end + return start, end + + +def anchor_position(markdown: str, figure: Figure) -> int | None: + plain, positions = searchable(markdown) + before, after = normalized(figure.text_before), normalized(figure.text_after) + for length in ANCHOR_LENGTHS: + tail, head = before[-length:], after[:length] + if len(tail) >= MIN_ANCHOR_CHARS and (found := plain.find(tail)) != -1: + return line_bounds(markdown, positions[found + len(tail) - 1])[1] + if len(head) >= MIN_ANCHOR_CHARS and (found := plain.find(head)) != -1: + return line_bounds(markdown, positions[found])[0] + return None + + +def insert_at(markdown: str, position: int, tag: str) -> str: + return f"{markdown[:position].rstrip()}\n\n{tag}\n\n{markdown[position:].lstrip()}" + + +def place_figures(markdown: str, figures: list[Figure], page_no: int) -> str: + if not figures: + return FIGURE_TOKEN_RE.sub("", markdown or "") + placed: set[int] = set() + + def swap_token(match: re.Match) -> str: + number = int(match.group(1)) + if not 1 <= number <= len(figures) or number in placed or figures[number - 1].url in markdown: + return "" + placed.add(number) + return figure_tag(figures[number - 1], number, page_no, match.group(2) or "") + + text = FIGURE_TOKEN_RE.sub(swap_token, markdown or "") + for number, figure in enumerate(figures, start=1): + if number in placed or not figure.url or figure.url in text: + continue + tag = figure_tag(figure, number, page_no) + position = anchor_position(text, figure) + if position is None: + position = 0 if figure.bbox[1] < figure.page_size[1] / 2 else len(text) + text = insert_at(text, position, tag) + return re.sub(r"\n{3,}", "\n\n", text).strip() diff --git a/wikify/engine/lint.py b/wikify/engine/lint.py index 617546f..8cabe38 100644 --- a/wikify/engine/lint.py +++ b/wikify/engine/lint.py @@ -132,3 +132,111 @@ def fix_table_separators(markdown: str) -> str: if i in insertions: out.append(insertions[i]) return "\n".join(out) + + +_BLANK_RUN_RE = re.compile(r"(? str: + """Escape form blanks (runs of 3+ underscores) so markdown can't read them as emphasis and eat + the text between two blanks. Idempotent; a line that is only underscores (a rule) is left alone.""" + lines = (markdown or "").split("\n") + for index in _prose_lines(lines): + if not _RULE_LINE_RE.match(lines[index]): + lines[index] = _BLANK_RUN_RE.sub(lambda match: "\\_" * len(match.group(0)), lines[index]) + return "\n".join(lines) + + +def _fix_bold_span(before: str, content: str, after: str) -> tuple[str, str, str]: + stripped = content.strip() + if not any(char.isalnum() for char in stripped): + return before + content, "", after + if opener := _TRAILING_OPENER_RE.search(stripped): + stripped, after = stripped[: opener.start()], stripped[opener.start() :] + after + if len(stripped) == 1 and stripped.isalpha() and after[:1].islower() and not before[-1:].isalnum(): + return before + stripped, "", after + if len(stripped) > 2 and stripped[-2] == " " and stripped[-1].isupper() and after[:1].islower(): + stripped, after = stripped[:-2], f" {stripped[-1]}{after}" + if content[:1].isspace() or (before[-1:].isalpha() and stripped[:1].isalpha() and len(stripped) > 1): + before += " " + if (content[-1:].isspace() and not after[:1].isspace()) or ( + _LETTER_START_RE.match(after) and len(stripped) > 1 + ): + after = f" {after}" + return before, f"**{stripped}**", after + + +def _fix_bold_spans(text: str) -> str: + parts = text.split("**") + if len(parts) % 2 == 0: + return _fix_bold_spans(text.rstrip()[:-2]) if text.rstrip().endswith("**") else text + for index in range(1, len(parts), 2): + parts[index - 1], parts[index], parts[index + 1] = _fix_bold_span( + parts[index - 1], parts[index], parts[index + 1] + ) + return "".join(parts) + + +def fix_glued_emphasis(markdown: str) -> str: + """Repair bold the PDF extraction glued to its neighbours: `**To**confirm`, `Helps**to**gain`, + `**P**revents`, `**Post: H**ypo`, `efficiency**.**`, a stray trailing `**`. Cell by cell in tables.""" + lines = (markdown or "").split("\n") + for index in _prose_lines(lines): + line = lines[index] + if "**" not in line: + continue + if _is_table_row(line): + lines[index] = "|".join(_fix_bold_spans(cell) for cell in _UNESCAPED_PIPE_RE.split(line)) + else: + lines[index] = _fix_bold_spans(line) + return "\n".join(lines) + + +def drop_box_bullets(markdown: str) -> str: + """Drop a box a parser read off a bullet glyph (`- ☐ Peritonitis rates`): a bulleted item never needs + a second marker. A list where any item is ticked or offers several boxes is a form and is kept.""" + lines = (markdown or "").split("\n") + prose = set(_prose_lines(lines)) + index = 0 + while index < len(lines): + end = index + while end < len(lines) and end in prose and _LIST_LINE_RE.match(lines[end]): + end += 1 + if end == index: + index += 1 + continue + if not any(_FORM_MARK_RE.search(lines[line]) for line in range(index, end)): + for line in range(index, end): + lines[line] = _BOX_BULLET_RE.sub(r"\1", lines[line]) + index = end + return "\n".join(lines) + + +def repair_markdown(markdown: str) -> str: + """Every mechanical repair, in one pass, for the assembled section product.""" + return drop_box_bullets(fix_glued_emphasis(escape_fill_in_blanks(fix_table_separators(markdown)))) diff --git a/wikify/engine/loader/cleanup.py b/wikify/engine/loader/cleanup.py index 6ff8236..387ee85 100644 --- a/wikify/engine/loader/cleanup.py +++ b/wikify/engine/loader/cleanup.py @@ -1,9 +1,9 @@ """Strip repeated page headers/footers before sectionizing. Real manuals repeat a running header/footer on every page (doc title, doc code, -"Pg X of Y", version/date). Left in, each becomes a fake heading. We remove lines -that recur across many pages, plus a few varying-boilerplate patterns (page -numbers, doc codes) that won't match exactly page-to-page. +"Pg X of Y", version/date). Left in, each becomes a fake heading. We remove the run of +lines at each page edge that recur at the edges of many pages, plus a few +varying-boilerplate patterns (page numbers, doc codes) that won't match exactly page-to-page. Ported verbatim from the POC `loader/cleanup.py` (pure markdown, no I/O). Wired into the pipeline at sectionize time (Slice 4); shipped here per the Slice 3 cleanup port. @@ -13,16 +13,33 @@ import re from collections import Counter +from itertools import groupby -_NORM = re.compile(r"[#*_`>\-\s]+") -_VARYING = [ +from wikify.engine.loader.table_stitch import ( + html_tables_to_markdown, + merge_continuation_rows, + stitch_cross_page_tables, +) + +_NORM = re.compile(r"[#*_`>|\-\s]+") +_MARKUP = re.compile(r"(?:|[*_`|]|^[\s>#]+)") +_PAGE_OF = [ re.compile(r"(?i)\bpg\.?\s*\d+\s*of\s*\d+"), re.compile(r"(?i)\bpage\s*\d+\s*of\s*\d+"), +] +_HEADER_FIELDS = [ re.compile(r"(?i)^man/[a-z0-9/]+"), re.compile(r"(?i)\bver\.?\s*:"), re.compile(r"(?i)\bissue\s*:\s*\d"), re.compile(r"(?i)^\s*date\s*:"), ] +_STRUCTURAL_LINE = re.compile(r"^\s*(?:<|```|~~~|!\[)") +_HTML_TABLE = re.compile(r"]*>.*?", re.IGNORECASE | re.DOTALL) +_HTML_CELL = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) +_HTML_TAG = re.compile(r"<[^>]+>") +_TABLE_CELL_SPLIT = re.compile(r"(?") +_IMAGE_LINE = re.compile(r"^\s*!\[[^\]]*\]\([^)]*\)\s*$") +EDGE_LINES = 4 # Approval / sign-off footer block — QMS-manual page furniture rendered as a one- or # two-row Markdown table, e.g. `|Prepared by - Dr X|Issued by: QMC|Approved by - Dr Y|`. @@ -31,24 +48,59 @@ # carrying >=2 distinct sign-off phrases (a lone "approved by" in a data row is kept). _SEP_ONLY = re.compile(r"^\s*\|[\s:|-]+\|\s*$") _SIGNOFF = ("prepared by", "issued by", "approved by", "reviewed by", "authorized by") +_SIGNOFF_LABEL = re.compile(r"(?i)\b(prepared|issued|approved|reviewed|authori[sz]ed) by\s*[:\-\u2013]") +_SIGNOFF_LINE = re.compile(r"(?i)^[*_\s]*(prepared|issued|approved|reviewed|authori[sz]ed) by\s*[:\-\u2013]") +_PAGE_NUMBER = re.compile(r"^\s*\d{1,4}\s*$") +_SENTENCE_START = re.compile(r"^[a-z]") +# A sentence cut by a page break ran to the page edge, so its last line is never a short label. +_MIN_BROKEN_LINE_LENGTH = 40 +BOILERPLATE_MAX_PAGES = 10 +# A numbered heading repeated as a running sub-header is still the real section start on its first +# page; the sectionizer folds the repeats, so stripping them here would lose the section itself. +_NUMBERED_HEADING = re.compile(r"^\s*#{1,6}\s+[*_]*\d+(?:\.\d+)*\.?\s*[A-Za-z]") +_LIST_ITEM = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+\S") +_ENUMERATED_TEXT = re.compile(r"^(\d+)[.)]\s") +_CONTENTS_LINE = re.compile(r"^(\d+(?:\.\d+)+\.?|\d+)\s+(\S.*?)\s*(?:\.{2,}\s*)?(\d{1,4})\s*$") +MIN_CONTENTS_LINES = 5 +_MERMAID_OPEN = re.compile(r"^\s*```\s*mermaid\b") +_FENCE_CLOSE = re.compile(r"^\s*```\s*$") +MAX_TABLE_TITLE_LINES = 2 +MAX_TABLE_TITLE_CHARS = 120 +MIN_TRANSCRIBED_ROWS = 4 + + +def _plain(line: str) -> str: + """The line's visible text: emphasis, table pipes,
, blockquote and heading markers removed.""" + return " ".join(_MARKUP.sub(" ", line).split()) def _norm(line: str) -> str: - return _NORM.sub(" ", line).strip().lower() + return _NORM.sub(" ", _plain(line)).strip().lower() def _is_signoff_footer_row(line: str) -> bool: s = line.strip() if not (s.startswith("|") and s.endswith("|")): - return False + return len({label.lower() for label in _SIGNOFF_LABEL.findall(s)}) >= 2 low = s.lower() return sum(kw in low for kw in _SIGNOFF) >= 2 +def _signoff_line_runs(lines: list[str]) -> set[int]: + """Sign-off labels laid out one per paragraph, e.g. "Prepared by: …" then "Issued by: …".""" + drop: set[int] = set() + non_blank = [index for index, line in enumerate(lines) if line.strip()] + for is_signoff, group in groupby(non_blank, key=lambda index: bool(_SIGNOFF_LINE.match(lines[index]))): + run = list(group) + if is_signoff and len({_SIGNOFF_LINE.match(lines[index]).group(1).lower() for index in run}) >= 2: + drop.update(run) + return drop + + def _strip_footer_blocks(md: str) -> str: """Drop sign-off footer rows and any separator row orphaned next to them.""" lines = md.splitlines() - drop: set[int] = set() + drop = _signoff_line_runs(lines) for i, line in enumerate(lines): if _is_signoff_footer_row(line): drop.add(i) @@ -60,34 +112,324 @@ def _strip_footer_blocks(md: str) -> str: return "\n".join(line for k, line in enumerate(lines) if k not in drop) +def _is_table_row(line: str) -> bool: + return line.strip().startswith("|") + + +def _strip_page_residue(md: str) -> str: + """Drop separator rows left headless by a stripped header box, and the closing page number.""" + lines = md.splitlines() + kept = [ + line + for index, line in enumerate(lines) + if not (_SEP_ONLY.match(line) and "-" in line and not (index and _is_table_row(lines[index - 1]))) + ] + while kept and not kept[-1].strip(): + kept.pop() + while kept and not kept[0].strip(): + kept.pop(0) + if kept and _PAGE_NUMBER.match(_plain(kept[-1])): + kept.pop() + return "\n".join(kept) + + +def _edge_lines(md: str) -> list[str]: + content = [line for line in md.splitlines() if _norm(line) and not _STRUCTURAL_LINE.match(line)] + return content[:EDGE_LINES] + content[-EDGE_LINES:] + + def find_boilerplate(pages: list[tuple[int, str]]) -> set[str]: - """Normalized lines that recur on a large fraction of pages.""" + """Normalized lines that recur at the top or bottom edge of a large fraction of pages.""" counts: Counter[str] = Counter() for _, md in pages: - for nl in {_norm(line) for line in md.splitlines() if _norm(line)}: + for nl in {_norm(line) for line in _edge_lines(md)}: counts[nl] += 1 - threshold = max(3, int(0.30 * len(pages))) + # Capped so a header that alternates between styles across a long document still counts. + threshold = max(3, min(int(0.30 * len(pages)), BOILERPLATE_MAX_PAGES)) return {line for line, c in counts.items() if c >= threshold and len(line) <= 90} -def _is_varying(line: str) -> bool: - return any(p.search(line) for p in _VARYING) +def _is_page_of(line: str) -> bool: + plain = _plain(line) + return any(pattern.search(plain) for pattern in _PAGE_OF) + + +def _is_page_furniture(line: str, boilerplate: set[str], at_top: bool) -> bool: + plain = _plain(line) + return ( + (_norm(line) in boilerplate and not _NUMBERED_HEADING.match(line)) + or any(pattern.search(plain) for pattern in _HEADER_FIELDS) + or bool(_PAGE_NUMBER.match(plain)) + or (at_top and "-" in line and bool(_SEP_ONLY.match(line))) + ) + + +def _edge_furniture(lines: list[str], boilerplate: set[str]) -> set[int]: + """The unbroken header/footer run at each page edge; a label that also recurs mid-page survives.""" + drop: set[int] = set() + for at_top, indexes in ((True, range(len(lines))), (False, range(len(lines) - 1, -1, -1))): + for index in indexes: + line = lines[index] + if not line.strip() or _IMAGE_LINE.match(line): + continue + if not _is_page_furniture(line, boilerplate, at_top): + break + drop.add(index) + return drop + + +def _is_header_text(text: str) -> bool: + plain = _plain(text) + return _is_page_of(text) or any(pattern.search(plain) for pattern in _HEADER_FIELDS) + + +def _is_header_cell(cell: str, boilerplate: set[str]) -> bool: + return _is_header_text(cell) or _is_page_furniture(cell, boilerplate, False) + + +def _is_header_block(cells: list[str], boilerplate: set[str]) -> bool: + """Cells that only carry a running header (title, doc code, version, page x of y) and name at least + one field of it, so a body table that merely repeats the document title is never mistaken for one.""" + filled = [cell for cell in cells if _plain(cell)] + return any(_is_header_text(cell) for cell in filled) and all( + _is_header_cell(cell, boilerplate) for cell in filled + ) + + +def _row_cells(line: str) -> list[str]: + return [cell for cell in _TABLE_CELL_SPLIT.split(line.strip()) if cell.strip()] + + +def _is_header_row(line: str, boilerplate: set[str]) -> bool: + cells = [cell for cell in _row_cells(line) if not _SEP_ONLY.match(f"|{cell}|")] + return all(_is_header_cell(cell, boilerplate) for cell in cells) + + +def _strip_html_header_tables(md: str, boilerplate: set[str]) -> str: + def strip(match: re.Match) -> str: + cells = [_HTML_TAG.sub(" ", cell) for cell in _HTML_CELL.findall(match.group(0))] + return "" if _is_header_block(cells, boilerplate) else match.group(0) + + return _HTML_TABLE.sub(strip, md) + + +def _split_at_header_rows(md: str, boilerplate: set[str]) -> list[str]: + """Split a page where a running header sits inside a table, which happens when an earlier pass + stitched the next page's table onto this one before its header was stripped.""" + lines = md.splitlines() + pieces: list[str] = [] + start = index = 0 + while index < len(lines): + end = index + while end < len(lines) and _is_table_row(lines[end]) and _is_header_row(lines[end], boilerplate): + end += 1 + if end > index and _is_header_block( + [cell for line in lines[index:end] for cell in _row_cells(line)], boilerplate + ): + pieces.append("\n".join(lines[start:index])) + start = end + index = max(end, index + 1) + pieces.append("\n".join(lines[start:])) + return pieces + + +def _strip_header_rows(md: str, boilerplate: set[str]) -> str: + pieces = _split_at_header_rows(md, boilerplate) + if len(pieces) == 1: + return md + rejoined = stitch_cross_page_tables(list(enumerate(pieces))) + return "\n\n".join(piece for _, piece in rejoined if piece.strip()) def strip_boilerplate(pages: list[tuple[int, str]], boilerplate: set[str]) -> list[tuple[int, str]]: out: list[tuple[int, str]] = [] for pno, md in pages: - kept = [ - line - for line in md.splitlines() - if not (_norm(line) and _norm(line) in boilerplate) and not _is_varying(line) - ] - out.append((pno, _strip_footer_blocks("\n".join(kept)))) + md = _strip_header_rows(_strip_html_header_tables(md, boilerplate), boilerplate) + lines = [line for line in _strip_footer_blocks(md).splitlines() if not _is_page_of(line)] + drop = _edge_furniture(lines, boilerplate) + kept = [line for index, line in enumerate(lines) if index not in drop] + out.append((pno, _strip_page_residue("\n".join(kept)))) return out +def _ends_mid_sentence(line: str) -> bool: + text = line.strip() + return ( + len(text) >= _MIN_BROKEN_LINE_LENGTH + and text[0] not in "#|<" + and (text[-1].isalnum() or text[-1] == ",") + ) + + +def join_page_breaks(pages: list[tuple[int, str]]) -> list[tuple[int, str]]: + """Rejoin a sentence the PDF split across a page break onto the page it started on.""" + page_lines = [md.splitlines() for _, md in pages] + for index in range(len(pages) - 1): + if pages[index + 1][0] != pages[index][0] + 1: + continue + lines, next_lines = page_lines[index], page_lines[index + 1] + last = next((i for i in range(len(lines) - 1, -1, -1) if lines[i].strip()), None) + first = next((i for i, line in enumerate(next_lines) if line.strip()), None) + if last is None or first is None: + continue + if _ends_mid_sentence(lines[last]) and _SENTENCE_START.match(next_lines[first].strip()): + lines[last] = f"{lines[last].rstrip()} {next_lines.pop(first).strip()}" + return [(page_no, "\n".join(lines)) for (page_no, _), lines in zip(pages, page_lines, strict=True)] + + +def list_kind(marker: str) -> str: + return "bullet" if marker in "-*+" else "ordered" + + +def last_list_item(lines: list[str]) -> re.Match | None: + for line in reversed(lines): + if not line.strip(): + continue + item = _LIST_ITEM.match(line) + if item or not line[0].isspace(): + return item + return None + + +def continuation_prefix(item: re.Match, first_line: str) -> str: + """What a list cut by a page break needs in front of its items on the next page, where it restarts + at the margin: the nesting it had, or the bullet it had when the bullets carried their own numbers.""" + follow = _LIST_ITEM.match(first_line) + if not follow or follow.group(1): + return "" + indent, marker = item.group(1), item.group(2) + if indent and list_kind(marker) == list_kind(follow.group(2)): + return indent + numbered_text = _ENUMERATED_TEXT.match(item.string[item.end(2) :].lstrip()) + if ( + list_kind(marker) == "bullet" + and list_kind(follow.group(2)) == "ordered" + and numbered_text + and int(follow.group(2)[:-1]) == int(numbered_text.group(1)) + 1 + ): + return f"{indent}{marker} " + return "" + + +def reindent_list_continuations(pages: list[tuple[int, str]]) -> list[tuple[int, str]]: + """Put a list continued across a page break back under the item it belongs to, up to the first + block that is not part of the list.""" + page_lines = [md.splitlines() for _, md in pages] + for index in range(1, len(pages)): + if pages[index][0] != pages[index - 1][0] + 1: + continue + item = last_list_item(page_lines[index - 1]) + lines = page_lines[index] + first = next((line for line in lines if line.strip()), "") + prefix = continuation_prefix(item, first) if item else "" + if not prefix: + continue + kind = list_kind(_LIST_ITEM.match(first).group(2)) + for line_index, line in enumerate(lines): + if not line.strip(): + continue + if line[0].isspace(): + lines[line_index] = " " * len(prefix) + line + continue + follow = _LIST_ITEM.match(line) + if not follow or list_kind(follow.group(2)) != kind: + break + lines[line_index] = prefix + line + return [(page_no, "\n".join(lines)) for (page_no, _), lines in zip(pages, page_lines, strict=True)] + + +def _contents_run(lines: list[str], start: int) -> tuple[int, list[re.Match]]: + entries: list[re.Match] = [] + end = start + while end < len(lines): + entry = _CONTENTS_LINE.match(lines[end].strip()) + if lines[end].strip() and not entry: + break + if entry: + entries.append(entry) + end += 1 + return end, entries + + +def contents_lines_to_table(md: str) -> str: + """Lay out a run of table-of-contents lines (number, title, page) as a headless table, so the page + stitches onto the contents table the pages around it were parsed as.""" + lines = md.splitlines() + out: list[str] = [] + index = 0 + while index < len(lines): + end, entries = _contents_run(lines, index) + if len(entries) < MIN_CONTENTS_LINES: + out.append(lines[index]) + index += 1 + continue + out.extend(["| | | |", "|---|---|---|"]) + out.extend(f"| {entry.group(1)} | {entry.group(2)} | {entry.group(3)} |" for entry in entries) + out.append("") + index = end + return "\n".join(out) + + +def _table_rows_from(lines: list[str], start: int) -> int: + if lines[start].lstrip().startswith(" tuple[str, int, int] | None: + """What the parser wrote for the picture straight after it: ("mermaid", fence start, fence end) for a + diagram redrawn from it, ("table", first line, rows) for a table typed out under its title.""" + following = [index for index in range(figure + 1, len(lines)) if lines[index].strip()] + if not following: + return None + first = following[0] + if _MERMAID_OPEN.match(lines[first]): + close = next((index for index in following[1:] if _FENCE_CLOSE.match(lines[index])), None) + return ("mermaid", first, close) if close is not None else None + for index in following[: MAX_TABLE_TITLE_LINES + 1]: + if _is_table_row(lines[index]) or lines[index].lstrip().startswith(" MAX_TABLE_TITLE_CHARS or _STRUCTURAL_LINE.match(lines[index]): + return None + return None + + +def drop_transcribed_figures(md: str) -> str: + """Keep one copy of a picture the parser also transcribed: the crop for a diagram it redrew as mermaid, + the typed table for a table printed as an image, since that copy is exact and searchable.""" + lines = md.splitlines() + drop: set[int] = set() + for index, line in enumerate(lines): + transcription = _IMAGE_LINE.match(line) and _transcription_after_figure(lines, index) + if not transcription: + continue + kind, start, end = transcription + if kind == "mermaid": + drop.update(range(start, end + 1)) + elif end >= MIN_TRANSCRIBED_ROWS: + drop.add(index) + if not drop: + return md + return "\n".join(line for index, line in enumerate(lines) if index not in drop) + + def clean_pages(pages: list[tuple[int, str]]) -> list[tuple[int, str]]: - return strip_boilerplate(pages, find_boilerplate(pages)) + pages = [(page_no, html_tables_to_markdown(md)) for page_no, md in pages] + stripped = [ + (page_no, contents_lines_to_table(drop_transcribed_figures(md))) + for page_no, md in strip_boilerplate(pages, find_boilerplate(pages)) + ] + stitched = stitch_cross_page_tables(stripped) + joined = join_page_breaks([(page_no, merge_continuation_rows(md)) for page_no, md in stitched]) + return reindent_list_continuations(joined) def strip_outer_markdown_fence(text: str) -> str: diff --git a/wikify/engine/loader/cleanup_llm.py b/wikify/engine/loader/cleanup_llm.py index a08dce8..f257150 100644 --- a/wikify/engine/loader/cleanup_llm.py +++ b/wikify/engine/loader/cleanup_llm.py @@ -28,6 +28,10 @@ "- Headings must be plain text: strip bold/italic emphasis from heading lines " "(e.g. `## _**Verbal Orders**_` → `## Verbal Orders`); never wrap a heading in * or _.\n" "- Keep ALL substantive text. Do NOT summarize, add, translate, or drop real content.\n" + "- Keep form checkboxes (☐ ☑; a symbol-font box such as \uf0a8 becomes ☐) and blank fill-in " + "lines (runs of underscores).\n" + "- Keep list markers as printed: letters (a. b.), roman numerals (i. ii.) and ✓ ticks are never " + "renumbered into 1. 2. 3.\n" "- Keep Markdown image embeds ![alt](url) exactly as they are — they are real figures.\n" "- Output ONLY the cleaned Markdown — no commentary, no code fences.\n\nMARKDOWN:\n" ) diff --git a/wikify/engine/loader/page_plan.py b/wikify/engine/loader/page_plan.py new file mode 100644 index 0000000..b16048a --- /dev/null +++ b/wikify/engine/loader/page_plan.py @@ -0,0 +1,445 @@ +"""Decide which sections become wiki pages; every other section folds into its nearest +page ancestor as an in-page heading. Generation mirrors sections 1:1, so this is where +wiki page granularity is set. +""" + +from __future__ import annotations + +import json +import re +from collections import Counter, defaultdict + +import frappe + +from wikify.engine import llm, settings +from wikify.engine.loader.context import context_block +from wikify.engine.loader.sectionizer import PREAMBLE_TITLE, Section, _section_number +from wikify.engine.store import resolve_parent_indexes + +FALLBACK_PAGE_NUMBER_DEPTH = 3 +FALLBACK_PAGE_TREE_DEPTH = 1 +FRAGMENT_WORDS = 40 +STUB_WORDS = 15 +LEADING_FRAGMENT_WORDS = 60 +MIN_SPLIT_PART_WORDS = 150 +MAX_PAGE_WORDS = 2500 +MAX_PAGE_PDF_PAGES = 6 +OWN_PAGE_WORDS = 150 +OUTLINE_BATCH_LINES = 250 + +_LIST_ITEM_TITLE = re.compile(r"^(?:\d+[.)]?|[A-Za-z][.)]|[IVXivx]+[.)]|[Ss]tep\s+\d+[:.)]?)\s") + +_PROMPT = ( + "You are planning how a PDF document becomes a wiki. Below is its section outline in " + "document order, one section per line: index | depth | title | PDF pages | own words | " + "words including subsections | child sections.\n\n" + "Decide which sections start their own wiki page. Every other section is merged into its " + "nearest ancestor page as an in-page heading, so no text is lost.\n" + "- A wiki page is one coherent topic a reader would bookmark, mirroring how the document's " + "own table of contents presents topics.\n" + "- Sub-points fold into their parent topic: deeper numbering (e.g. 4.2.1.3 under 4.2.1), " + "bold or unnumbered sub-headings, and list-like headings (e.g. 'Class I', 'Type 2', " + "'1. Dosage').\n" + "- Sections that start on the same PDF page and belong to the same parent topic should " + "end up on the same wiki page.\n" + "- Avoid tiny pages (under ~150 words including their subsections) unless the section is " + "a standalone policy or topic.\n" + "- Split a very long topic (over ~4000 words) only at its natural numbered sub-sections.\n" + "- Numbered depth-1 sections are always pages; an unnumbered depth-1 fragment (a form label, a " + "caption, a stray sentence) is not, and merges into the page before it. A section can only be a " + "page if its parent is one.\n\n" + 'Respond ONLY as JSON: {"pages": []}\n\n' +) + + +def plan_pages(sections: list[Section], project_context: str = "", use_llm: bool = False) -> list[Section]: + parents = resolve_parent_indexes(sections) + chosen = llm_pages(sections, parents, project_context) if use_llm else fallback_pages(sections, parents) + pages, parents = enforce_invariants(sections, parents, chosen) + return fold_sections(sections, parents, pages) + + +def tree_depths(parents: list[int | None]) -> list[int]: + depths: list[int] = [] + for parent in parents: + depths.append(0 if parent is None else depths[parent] + 1) + return depths + + +def outline_numbers(sections: list[Section], parents: list[int | None]) -> list[tuple[int, ...] | None]: + """A heading's number only counts when it extends its nearest numbered ancestor's, so a + list item like "1. Dosage" under 4.2.1 reads as unnumbered.""" + numbers: list[tuple[int, ...] | None] = [] + numbered_ancestor: list[int | None] = [] + for index, section in enumerate(sections): + parent = parents[index] + ancestor = None if parent is None else (parent if numbers[parent] else numbered_ancestor[parent]) + number = _section_number(section.title) + if number and ancestor is not None: + prefix = numbers[ancestor] + if len(number) <= len(prefix) or number[: len(prefix)] != prefix: + number = None + numbers.append(number) + numbered_ancestor.append(ancestor) + return numbers + + +def fallback_pages(sections: list[Section], parents: list[int | None]) -> set[int]: + numbers = outline_numbers(sections, parents) + depths = tree_depths(parents) + pages: set[int] = set() + for index in range(len(sections)): + number = numbers[index] + has_numbered_ancestor = any(numbers[ancestor] for ancestor in ancestors(parents, index)) + if ( + parents[index] is None + or (number and len(number) <= FALLBACK_PAGE_NUMBER_DEPTH) + or (not number and not has_numbered_ancestor and depths[index] <= FALLBACK_PAGE_TREE_DEPTH) + ): + pages.add(index) + return pages + + +def ancestors(parents: list[int | None], index: int): + parent = parents[index] + while parent is not None: + yield parent + parent = parents[parent] + + +def enforce_invariants( + sections: list[Section], parents: list[int | None], chosen: set[int] +) -> tuple[set[int], list[int | None]]: + numbers = outline_numbers(sections, parents) + child_counts = Counter(parent for parent in parents if parent is not None) + pages: set[int] = set() + for index, parent in enumerate(parents): + if parent is None: + words = len(sections[index].markdown.split()) + fragment = index not in child_counts and words < FRAGMENT_WORDS + leading_fragment = ( + index == 0 + and len(sections) > 1 + and index not in child_counts + and not numbers[index] + and words < LEADING_FRAGMENT_WORDS + ) + if not leading_fragment and (index == 0 or numbers[index] or (index in chosen and not fragment)): + pages.add(index) + elif index in chosen and parent in pages: + pages.add(index) + pages = add_numbered_siblings(parents, numbers, pages) + pages, parents = split_long_pages(sections, parents, numbers, pages) + pages, parents = keep_sibling_order(sections, parents, numbers, pages) + pages, parents = keep_pdf_pages_whole(sections, parents, numbers, pages) + child_counts = Counter(parent for parent in parents if parent is not None) + pages = { + index + for index in pages + if parents[index] is None + or index in child_counts + or (sections[index].markdown.strip() and child_counts[parents[index]] > 1) + } + return pages, parents + + +def add_numbered_siblings( + parents: list[int | None], numbers: list[tuple[int, ...] | None], pages: set[int] +) -> set[int]: + """Numbered siblings are pages alike: once most of them are, the rest follow.""" + numbered_children: dict[int, list[int]] = defaultdict(list) + for index, parent in enumerate(parents): + if parent is not None and numbers[index]: + numbered_children[parent].append(index) + pages = set(pages) + for parent in sorted(numbered_children): + siblings = numbered_children[parent] + if parent in pages and 2 * len(pages.intersection(siblings)) > len(siblings): + pages.update(siblings) + return pages + + +def keep_sibling_order( + sections: list[Section], parents: list[int | None], numbers: list[tuple[int, ...] | None], pages: set[int] +) -> tuple[set[int], list[int | None]]: + """A numbered sibling after a page must not fold into the parent, which would show it before that + page: it becomes a page, or joins the last page before it when it is a stub or a list-item fragment.""" + parents = list(parents) + subtree_words = [len(section.markdown.split()) for section in sections] + for index in reversed(range(len(sections))): + if parents[index] is not None: + subtree_words[parents[index]] += subtree_words[index] + numbered_children: dict[int, list[int]] = defaultdict(list) + for index, parent in enumerate(parents): + if parent in pages and numbers[index]: + numbered_children[parent].append(index) + pages = set(pages) + for siblings in numbered_children.values(): + previous_page = None + for sibling in siblings: + if sibling in pages: + previous_page = sibling + elif previous_page is not None and ( + subtree_words[sibling] >= FRAGMENT_WORDS + or ( + (subtree_words[sibling] >= STUB_WORDS or opens_pdf_page(sections, sibling)) + and not _LIST_ITEM_TITLE.match(sections[sibling].title) + ) + ): + pages.add(sibling) + previous_page = sibling + elif previous_page is not None: + parents[sibling] = max(page for page in pages if page < sibling) + return pages, parents + + +def keep_pdf_pages_whole( + sections: list[Section], parents: list[int | None], numbers: list[tuple[int, ...] | None], pages: set[int] +) -> tuple[set[int], list[int | None]]: + """A short section that shares a PDF page joins the wiki page before it, unless that makes the page too + long to read in one scroll. A numbered section long enough to stand alone keeps its own page.""" + parents = list(parents) + page_parents = {parents[page] for page in pages} + words: Counter[int] = Counter() + last_pdf_page: dict[int, int] = {} + for index, owner in enumerate(page_owners(parents, pages)): + if owner is not None: + words[owner] += len(sections[index].markdown.split()) + last_pdf_page[owner] = max(last_pdf_page.get(owner, 0), sections[index].page_end) + pages = set(pages) + for index in sorted(pages): + if ( + parents[index] is None + or index in page_parents + or (numbers[index] and words[index] >= OWN_PAGE_WORDS) + or opens_pdf_page(sections, index) + ): + continue + target = max((page for page in pages if page < index), default=None) + if target is None: + continue + joined_pdf_pages = max(last_pdf_page[target], last_pdf_page[index]) - sections[target].page_start + 1 + if words[target] + words[index] > MAX_PAGE_WORDS or joined_pdf_pages > MAX_PAGE_PDF_PAGES: + continue + pages.discard(index) + parents[index] = target + words[target] += words[index] + last_pdf_page[target] = max(last_pdf_page[target], last_pdf_page[index]) + return pages, parents + + +def page_owners(parents: list[int | None], pages: set[int]) -> list[int | None]: + owners: list[int | None] = [] + for index, parent in enumerate(parents): + if index in pages: + owners.append(index) + elif parent is not None: + owners.append(owners[parent]) + else: + owners.append(owners[-1] if owners else None) + return owners + + +def opens_pdf_page(sections: list[Section], index: int) -> bool: + page_start = sections[index].page_start + for earlier in reversed(sections[:index]): + if earlier.page_end < page_start: + return True + if earlier.markdown.strip(): + return False + return True + + +def split_long_pages( + sections: list[Section], parents: list[int | None], numbers: list[tuple[int, ...] | None], pages: set[int] +) -> tuple[set[int], list[int | None]]: + """A page too long to read in one scroll is split at its numbered children, recursively; tiny + children stay folded. Without numbered children it splits at its sub-headings instead.""" + parents = list(parents) + children: dict[int, list[int]] = defaultdict(list) + subtree_words = [len(section.markdown.split()) for section in sections] + for index in reversed(range(len(sections))): + parent = parents[index] + if parent is not None: + children[parent].insert(0, index) + subtree_words[parent] += subtree_words[index] + pages = set(pages) + queue = sorted(pages) + while queue: + page = queue.pop(0) + folded, pending = [], [page] + while pending: + index = pending.pop() + folded.append(index) + pending.extend(child for child in children[index] if child not in pages) + words = sum(len(sections[index].markdown.split()) for index in folded) + pdf_pages = max(sections[index].page_end for index in folded) - sections[page].page_start + 1 + if words <= MAX_PAGE_WORDS and pdf_pages <= MAX_PAGE_PDF_PAGES: + continue + split = [ + child + for child in children[page] + if numbers[child] and child not in pages and subtree_words[child] >= FRAGMENT_WORDS + ] + if not split: + for part in heading_parts(sections, children[page], subtree_words): + split.append(part[0]) + for member in part[1:]: + parents[member] = part[0] + children[part[0]].append(member) + children[page] = [child for child in children[page] if child not in part[1:]] + pages.update(split) + queue.extend(split) + return pages, parents + + +def heading_parts(sections: list[Section], children: list[int], subtree_words: list[int]) -> list[list[int]]: + """Runs of children that each start at a sub-heading and hold at least MIN_SPLIT_PART_WORDS; a + tiny run joins the run before it, or the run after it when it is a bare heading. A title the + document repeats, or a stack of bare headings, is a form letterhead, never a part's title.""" + title_counts = Counter(section.title.lower() for section in sections) + bare = [not sections[child].markdown.strip() for child in children] + starts = [ + child + for position, child in enumerate(children) + if not _LIST_ITEM_TITLE.match(sections[child].title) + and title_counts[sections[child].title.lower()] == 1 + and not in_bare_stack(bare, position) + ] + if len(starts) < 2: + starts = children + parts: list[list[int]] = [] + for child in children: + if child in starts: + parts.append([child]) + elif parts: + parts[-1].append(child) + merged: list[list[int]] = [] + carried: list[int] = [] + for part in parts: + part = carried + part + carried = [] + if sum(subtree_words[child] for child in part) >= MIN_SPLIT_PART_WORDS: + merged.append(part) + elif merged and sections[part[0]].markdown.strip(): + merged[-1].extend(part) + else: + carried = part + if carried and merged: + merged[-1].extend(carried) + elif carried: + merged.append(carried) + return merged if len(merged) > 1 else [] + + +def in_bare_stack(bare: list[bool], position: int) -> bool: + before = position > 0 and bare[position - 1] + after = position + 1 < len(bare) and bare[position + 1] + return bare[position] and (before or after) + + +def fold_sections(sections: list[Section], parents: list[int | None], pages: set[int]) -> list[Section]: + owner: dict[int, int] = {} + depth_below_page: dict[int, int] = {} + page_parents = {parents[index] for index in pages} + leading: list[str] = [] + for index, section in enumerate(sections): + if index in pages: + owner[index], depth_below_page[index] = index, 0 + if leading and index not in page_parents: + section.markdown = "\n\n".join(part for part in (*leading, section.markdown.strip()) if part) + section.page_start = min(section.page_start, sections[0].page_start) + leading = [] + continue + parent = parents[index] + if parent is None and index - 1 not in owner: + title = "" if section.title == PREAMBLE_TITLE else f"**{section.title}**" + leading.extend(part for part in (title, section.markdown.strip()) if part) + continue + if parent is None: + owner[index], depth_below_page[index] = owner[index - 1], 1 + else: + owner[index], depth_below_page[index] = owner[parent], depth_below_page[parent] + 1 + page = sections[owner[index]] + heading = f"{'#' * min(6, depth_below_page[index] + 1)} {section.title}" + page.markdown = "\n\n".join( + part for part in (page.markdown.strip(), heading, section.markdown.strip()) if part + ) + page.page_end = max(page.page_end, section.page_end) + return [section for index, section in enumerate(sections) if index in pages] + + +def llm_pages(sections: list[Section], parents: list[int | None], project_context: str) -> set[int]: + chosen: set[int] = set() + for batch in outline_batches(parents): + chosen |= ask_for_pages(sections, parents, batch, project_context) + return chosen + + +def outline_batches(parents: list[int | None]) -> list[list[int]]: + chapters: list[list[int]] = [] + for index, parent in enumerate(parents): + if parent is None: + chapters.append([]) + chapters[-1].append(index) + batches: list[list[int]] = [] + for chapter in chapters: + if batches and len(batches[-1]) + len(chapter) <= OUTLINE_BATCH_LINES: + batches[-1].extend(chapter) + else: + batches.append(list(chapter)) + return batches + + +def format_outline(sections: list[Section], parents: list[int | None], batch: list[int]) -> str: + depths = tree_depths(parents) + own_words = [len(section.markdown.split()) for section in sections] + total_words = list(own_words) + child_counts = [0] * len(sections) + for index in reversed(range(len(sections))): + parent = parents[index] + if parent is not None: + total_words[parent] += total_words[index] + child_counts[parent] += 1 + lines = [] + for index in batch: + section = sections[index] + fields = ( + index, + depths[index] + 1, + section.title, + f"p{section.page_start}-{section.page_end}", + own_words[index], + total_words[index], + child_counts[index], + ) + lines.append(" " * depths[index] + " | ".join(str(field) for field in fields)) + return "\n".join(lines) + + +def ask_for_pages( + sections: list[Section], parents: list[int | None], batch: list[int], project_context: str +) -> set[int]: + prompt = ( + context_block(project_context) + _PROMPT + "OUTLINE:\n" + format_outline(sections, parents, batch) + ) + try: + response = llm.chat_completion( + settings.get("classifier_model"), + [{"role": "user", "content": prompt}], + label="page_plan", + response_format={"type": "json_object"}, + max_tokens=4096, + timeout=300, + ) + pages = json.loads(response["choices"][0]["message"]["content"] or "{}").get("pages") + if isinstance(pages, str): + # The Claude CLI backend's open object schema returns the list JSON-encoded. + pages = json.loads(pages) + except Exception: + frappe.log_error(title="Wikify page plan failed") + pages = None + batch_indexes = set(batch) + if not isinstance(pages, list): + return fallback_pages(sections, parents) & batch_indexes + return {index for index in pages if type(index) is int and index in batch_indexes} diff --git a/wikify/engine/loader/sectionizer.py b/wikify/engine/loader/sectionizer.py index fe80407..74ee8f9 100644 --- a/wikify/engine/loader/sectionizer.py +++ b/wikify/engine/loader/sectionizer.py @@ -1,12 +1,16 @@ from __future__ import annotations import re +from bisect import bisect_right from collections import defaultdict from dataclasses import dataclass +from itertools import pairwise +from wikify.engine.loader.cleanup import _SEP_ONLY from wikify.engine.loader.toc import correct_level MAX_TITLE_LENGTH = 140 +PREAMBLE_TITLE = "Preamble" RUNNING_HEADER_MIN_PAGES = 3 TOP_OF_PAGE_LINES = 3 @@ -15,10 +19,27 @@ _NUM_RE = re.compile(r"^(\d+(?:\.\d+)*)\.?\s+\S") _LEADING_NUM = re.compile(r"^(\d+)\b") _DOUBLE_NUM = re.compile(r"^\d+\.\s+\d") +_GLUED_NUM = re.compile(r"^(\d+(?:\.\d+)+\.?)(?=[A-Z])") +_BOLD_LINE = re.compile(r"^\*\*([^*]+)\*\*$") +_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)") +_HTML_TAG = re.compile(r"]*>") +_LETTERED_NUM = re.compile(r"^(\d+(?:\.\d+)+)\.[a-z](?![a-z])\s*") +_TOC_NUMBERED = re.compile(r"^(\d+(?:\.\d+)*)\.?\s+(.*?)(?:\s*\.{3,})?(?:\s+\d{1,4})?$") +_CONTENTS_TITLE = re.compile(r"(?i)^(?:table of )?contents$|^index$") +_TOC_ENTRY = re.compile( + r"^(?:[-*+]\s+)?\|?\s*\d+(?:\.\d+)*\.?\s*\|?\s*[A-Za-z(].*?(?:\.{3,}|\s[-\u2013\u2014]\s|\||
|\s)\s*(\d{1,4})\s*\|?$" +) +_TOC_LEADER = re.compile(r"[A-Za-z].*\.{4,}\s*(\d{1,4})$") +TOC_MIN_ENTRIES = 5 +TOC_MIN_ENTRY_SHARE = 0.6 +TOC_MIN_ASCENDING_SHARE = 0.75 def _clean_title(raw: str) -> str: - title = raw.strip().strip("*_").strip() + text = _HTML_TAG.sub("", raw).replace("**", "").strip().strip("*_").strip() + title = _GLUED_NUM.sub(r"\1 ", _LINK.sub(r"\1", text).strip()) + if title.endswith(".") and not title.endswith(".."): + title = title[:-1].rstrip() if len(title) <= MAX_TITLE_LENGTH: return title clipped = title[: MAX_TITLE_LENGTH - 1] @@ -33,6 +54,12 @@ def _infer_level(title: str, fallback: int) -> int: return fallback +def _is_label(title: str, level_map: dict[str, int]) -> bool: + return ( + title not in level_map and not _NUM_RE.match(title) and (title[:1].islower() or title.endswith(":")) + ) + + def _chapter_num(title: str) -> int | None: m = _LEADING_NUM.match(title) return int(m.group(1)) if m else None @@ -42,6 +69,266 @@ def _looks_like_list_item(title: str) -> bool: return "*" in title or bool(_DOUBLE_NUM.match(title)) +def _section_number(title: str) -> tuple[int, ...] | None: + m = _NUM_RE.match(title) + return tuple(int(part) for part in m.group(1).split(".")) if m else None + + +def _title_words(title: str) -> list[str]: + m = _NUM_RE.match(title) + text = title[m.end(1) :] if m else title + return [word.removesuffix("s") for word in re.findall(r"[a-z0-9]+", text.lower())] + + +def _repeats_open_section(title: str, stack: list[tuple[int, str, bool]]) -> bool: + number = _section_number(title) + if number is None: + return False + words = _title_words(title) + return any( + numbered + and _section_number(open_title)[: len(number)] == number + and _title_words(open_title) == words + for _, open_title, numbered in stack + ) + + +def extends_number(number: tuple[int, ...] | None, prefix: tuple[int, ...]) -> bool: + return bool(number) and len(number) > len(prefix) and number[: len(prefix)] == prefix + + +def dotted_heading_positions(pages: list[tuple[int, str]]) -> list[tuple[int, int, tuple[int, ...]]]: + positions = [] + for page_index, (_, md) in enumerate(pages): + for line_index, line in enumerate(md.splitlines()): + match = _HEADING_RE.match(line) + number = _section_number(_clean_title(match.group(2))) if match else None + if number and len(number) > 1: + positions.append((page_index, line_index, number)) + return positions + + +def opens_next_chapter( + chapter: int, last_number: tuple[int, ...], next_dotted: tuple[int, ...] | None, title: str +) -> bool: + """Inside a numbered sub-section, "4." is a list item unless it is the next chapter: the next dotted + heading no longer belongs to the current chapter, or, with none left to read, it is set in capitals + like a chapter title.""" + if chapter != last_number[0] + 1: + return False + return next_dotted[0] >= chapter if next_dotted else title.isupper() + + +def adopt_preceding_descendants( + sections: list[Section], + current: Section | None, + stack: list[tuple[int, str, bool]], + title: str, + level: int, + page_no: int, +) -> None: + """A parent heading that resurfaces after its sub-sections were read (an excerpt that starts + mid-section) becomes their group instead of a new section after them.""" + number = _section_number(title) + + def descends(section: Section) -> bool: + return any( + extends_number(_section_number(path_title), number) for path_title in section.hierarchy_path + ) + + run = [*sections, current] if current else list(sections) + start = len(run) + while start and descends(run[start - 1]): + start -= 1 + if start == len(run): + return + for section in run[start:]: + depth = next( + index + for index, path_title in enumerate(section.hierarchy_path) + if extends_number(_section_number(path_title), number) + ) + section.hierarchy_path.insert(depth, title) + group_path = run[start].hierarchy_path[: run[start].hierarchy_path.index(title) + 1] + sections.insert( + start, + Section( + title=title, + level=level, + hierarchy_path=list(group_path), + page_start=run[start].page_start, + page_end=page_no, + ), + ) + stack_index = next( + ( + index + for index, (_, open_title, numbered) in enumerate(stack) + if numbered and extends_number(_section_number(open_title), number) + ), + len(stack), + ) + stack.insert(stack_index, (level, title, True)) + + +def _continues_numbering(number: tuple[int, ...], last_number: tuple[int, ...]) -> bool: + if number == (*last_number, 1): + return True + depth = len(number) + return ( + depth <= len(last_number) + and number[:-1] == last_number[: depth - 1] + and number[-1] == last_number[depth - 1] + 1 + ) + + +def _promote_numbered_bold_line( + line: str, last_number: tuple[int, ...], next_dotted: tuple[int, ...] | None +) -> str: + bold = _BOLD_LINE.match(line.strip()) + if not (bold and last_number): + return line + title = bold.group(1).strip() + number = _section_number(title) + if number is None or not _continues_numbering(number, last_number): + return line + if ( + len(number) == 1 + and len(last_number) > 1 + and not opens_next_chapter(number[0], last_number, next_dotted, title) + ): + return line + return f"{'#' * min(6, len(number))} {title}" + + +def next_dotted_number( + dotted_headings: list[tuple[int, int, tuple[int, ...]]], page_index: int, line_index: int +) -> tuple[int, ...] | None: + index = bisect_right(dotted_headings, (page_index, line_index), key=lambda heading: heading[:2]) + return dotted_headings[index][2] if index < len(dotted_headings) else None + + +def correct_chapter_typo(title: str, last_number: tuple[int, ...]) -> str: + """A deep number whose chapter jumps while its tail carries on the current numbering ("1.5.3.1.5" + after 6.5.3.1.4) is a misprint of the current chapter.""" + match = _NUM_RE.match(title) + if not (match and last_number): + return title + number = tuple(int(part) for part in match.group(1).split(".")) + corrected = (last_number[0], *number[1:]) + if len(number) < 3 or number[0] == last_number[0] or not _continues_numbering(corrected, last_number): + return title + return ".".join(str(part) for part in corrected) + title[match.end(1) :] + + +def toc_section_titles(pages: list[tuple[int, str]]) -> dict[tuple[int, ...], str]: + titles: dict[tuple[int, ...], str] = {} + for _, md in pages: + lines = md.splitlines() + for line in lines[: toc_end_line(lines) + 1]: + text = " ".join(_HTML_TAG.sub(" ", line).replace("|", " ").split()).lstrip("-*+ ") + entry = _TOC_NUMBERED.match(text) + if entry and entry.group(2): + number = tuple(int(part) for part in entry.group(1).split(".")) + titles.setdefault(number, _clean_title(f"{entry.group(1)} {entry.group(2)}")) + return titles + + +def recurring_section_titles(pages: list[tuple[int, str]]) -> dict[tuple[int, ...], list[str]]: + """Numbered heading titles printed on more than one page, in reading order: running headers that + name a section even where the body never opens it.""" + pages_seen: dict[str, set[int]] = defaultdict(set) + for page_no, md in pages: + for line in md.splitlines(): + match = _HEADING_RE.match(line) + if match: + pages_seen[_clean_title(match.group(2))].add(page_no) + recurring: dict[tuple[int, ...], list[str]] = defaultdict(list) + for title, seen in pages_seen.items(): + number = _section_number(title) + if number and len(seen) > 1: + recurring[number].append(title) + return recurring + + +def repeats_running_header( + title: str, stack: list[tuple[int, str, bool]], recurring: dict[tuple[int, ...], list[str]] +) -> bool: + """A lettered number ("6.2.1.b POLICIES…") under its open section 6.2.1 is that section's running + header "6.2.1 POLICIES…" printed with a suffix.""" + lettered = _LETTERED_NUM.match(title) + if not lettered: + return False + base = tuple(int(part) for part in lettered.group(1).split(".")) + words = _title_words(title[lettered.end() :]) + return any(numbered and _section_number(open_title) == base for _, open_title, numbered in stack) and any( + _title_words(running) == words for running in recurring.get(base, ()) + ) + + +def missing_ancestors( + number: tuple[int, ...], stack: list[tuple[int, str, bool]], last_at_depth: dict[int, tuple[int, ...]] +) -> list[tuple[int, ...]]: + """Numeric ancestors of a heading that the body never opened, where the numbering shows they belong: + the last number read at that depth is the ancestor's predecessor (6.1 after chapter 5).""" + deepest = max( + (len(_section_number(open_title)) for _, open_title, numbered in stack if numbered), default=0 + ) + missing = [] + for depth in range(deepest + 1, len(number)): + prefix = number[:depth] + previous = last_at_depth.get(depth) + if previous and previous[:-1] == prefix[:-1] and previous[-1] + 1 == prefix[-1]: + missing.append(prefix) + return missing + + +def ancestor_title( + prefix: tuple[int, ...], + recurring: dict[tuple[int, ...], list[str]], + toc_titles: dict[tuple[int, ...], str], + child_title: str, +) -> str: + if recurring.get(prefix): + return recurring[prefix][0] + if prefix in toc_titles: + return toc_titles[prefix] + label = ".".join(str(part) for part in prefix) + child = _NUM_RE.match(child_title) + if _section_number(child_title) == (*prefix, 0): + return label + child_title[child.end(1) :] + return label + + +def is_bare_heading_for(section: Section, title: str) -> bool: + return ( + not section.markdown + and not _NUM_RE.match(section.title) + and _title_words(section.title) == _title_words(title) + ) + + +def toc_end_line(lines: list[str]) -> int: + """Index of the last table-of-contents entry when the page is a TOC page, else -1. TOC entries name + real headings, so reading them as headings opens phantom sections and advances chapter numbering.""" + entries: list[tuple[int, int]] = [] + content_lines = 0 + for index, line in enumerate(lines): + text = line.strip() + if not text or _SEP_ONLY.match(text): + continue + content_lines += 1 + entry = _TOC_ENTRY.match(text) or _TOC_LEADER.search(text) + if entry: + entries.append((index, int(entry.group(1)))) + if len(entries) < TOC_MIN_ENTRIES or len(entries) < TOC_MIN_ENTRY_SHARE * content_lines: + return -1 + ascending = sum(later >= earlier for (_, earlier), (_, later) in pairwise(entries)) + if ascending < TOC_MIN_ASCENDING_SHARE * (len(entries) - 1): + return -1 + return entries[-1][0] + + def running_header_titles(pages: list[tuple[int, str]]) -> set[str]: occurrences: dict[str, list[int]] = defaultdict(list) pages_seen: dict[str, set[int]] = defaultdict(set) @@ -81,19 +368,73 @@ def sectionize(pages: list[tuple[int, str]], level_map: dict[str, int] | None = max_chapter = 0 capital_chapters = True last_list_item = (0, 0) + last_number: tuple[int, ...] = () running_headers = running_header_titles(pages) opened_headers: set[str] = set() + dotted_headings = dotted_heading_positions(pages) + recurring_titles = recurring_section_titles(pages) + toc_titles = toc_section_titles(pages) + last_at_depth: dict[int, tuple[int, ...]] = {} def flush(): if current is not None: current.markdown = "\n".join(buf).strip() sections.append(current) - for page_no, md in pages: - for line in md.splitlines(): - m = _HEADING_RE.match(line) + for page_index, (page_no, md) in enumerate(pages): + # Without a blank line, GFM reads the next page's first line as another row of a table, more + # raw HTML, or a lazy continuation of a list item that ended the previous page. + if buf and buf[-1].strip(): + buf.append("") + lines = md.splitlines() + toc_end = toc_end_line(lines) + first_line = next((index for index, line in enumerate(lines) if line.strip()), -1) + for line_index, line in enumerate(lines): + in_toc = line_index <= toc_end and bool(line.strip()) + next_dotted = next_dotted_number(dotted_headings, page_index, line_index) + m = _HEADING_RE.match( + line if in_toc else _promote_numbered_bold_line(line, last_number, next_dotted) + ) + title = _clean_title(m.group(2)) if m else "" + if in_toc: + if not (current and _CONTENTS_TITLE.match(current.title)): + flush() + buf = [] + contents_title = title if _CONTENTS_TITLE.match(title) else "Contents" + stack = [(1, contents_title, False)] + current = Section( + title=contents_title, + level=1, + hierarchy_path=[contents_title], + page_start=page_no, + page_end=page_no, + ) + if contents_title == title: + continue + m = None + elif m and _is_label(title, level_map): + line, m = f"**{title}**", None if m: - title = _clean_title(m.group(2)) + title = correct_chapter_typo(title, last_number) + if _repeats_open_section(title, stack) or repeats_running_header( + title, stack, recurring_titles + ): + if current is not None: + current.page_end = page_no + continue + number = _section_number(title) + if number and extends_number(last_number, number): + # Numbering that steps back to an ancestor is that ancestor's running header. + if len(number) > 1: + if not any(_section_number(open_title) == number for _, open_title, _ in stack): + level = correct_level(title, _infer_level(title, len(m.group(1))), level_map) + adopt_preceding_descendants(sections, current, stack, title, level, page_no) + if current is not None: + current.page_end = page_no + continue + next_line = next((text for text in lines[line_index + 1 :] if text.strip()), "") + if line_index == first_line and _HEADING_RE.match(next_line): + continue if title in running_headers: if title in opened_headers: if current is not None: @@ -110,6 +451,12 @@ def flush(): if ( _looks_like_list_item(title) or cnum <= max_chapter + or (last_number and cnum <= last_number[0]) + or ( + len(last_number) > 1 + and (next_dotted or not max_chapter) + and not opens_next_chapter(cnum, last_number, next_dotted, title) + ) or (max_chapter and capital_chapters and not title.isupper()) or (page_no, cnum - 1) == last_list_item ): @@ -123,9 +470,39 @@ def flush(): anchor = next((lvl for lvl, _, numbered in reversed(stack) if numbered), None) if anchor is not None: level = min(6, max(level, anchor + 1)) - while stack and stack[-1][0] >= level: + numbered_heading = not demoted and bool(_NUM_RE.match(title)) + while stack and ( + stack[-1][0] >= level + or ( + numbered_heading + and not (stack[-1][2] and extends_number(number, _section_number(stack[-1][1]))) + ) + ): stack.pop() - stack.append((level, title, not demoted and bool(_NUM_RE.match(title)))) + if numbered_heading: + for prefix in missing_ancestors(number, stack, last_at_depth): + group_title = ancestor_title(prefix, recurring_titles, toc_titles, title) + group_level = max(1, level - len(number) + len(prefix)) + group_start = page_no + if sections and is_bare_heading_for(sections[-1], group_title): + group_start = sections.pop().page_start + stack.append((group_level, group_title, True)) + sections.append( + Section( + title=group_title, + level=group_level, + hierarchy_path=[t for _, t, _ in stack], + page_start=group_start, + page_end=page_no, + ) + ) + last_at_depth[len(prefix)] = prefix + if len(prefix) == 1: + max_chapter = max(max_chapter, prefix[0]) + stack.append((level, title, numbered_heading)) + if numbered_heading: + last_number = number + last_at_depth[len(number)] = number current = Section( title=title, level=level, @@ -134,6 +511,14 @@ def flush(): page_end=page_no, ) else: + if current is None and line.strip(): + current = Section( + title=PREAMBLE_TITLE, + level=1, + hierarchy_path=[PREAMBLE_TITLE], + page_start=page_no, + page_end=page_no, + ) if current is not None: current.page_end = page_no buf.append(line) @@ -157,19 +542,4 @@ def flush(): merged.append(sec) if is_numbered: numbered[key] = sec - sections = merged - - if not sections and pages: - whole = "\n".join(md for _, md in pages).strip() - if whole: - sections.append( - Section( - title="Preamble", - level=1, - hierarchy_path=["Preamble"], - page_start=pages[0][0], - page_end=pages[-1][0], - markdown=whole, - ) - ) - return sections + return merged diff --git a/wikify/engine/loader/table_stitch.py b/wikify/engine/loader/table_stitch.py index 89aa693..75e92fb 100644 --- a/wikify/engine/loader/table_stitch.py +++ b/wikify/engine/loader/table_stitch.py @@ -1,8 +1,9 @@ """Merge tables that pymupdf split across a page boundary. If page N ends with a Markdown table and page N+1 begins with one of the same -column count, the rows are joined into a single table (a repeated header on the -continuation page is dropped). Heuristic, but cheap and reversible. +column count, the rows are joined into a single table (a repeated or empty header on +the continuation page is dropped; any other header is kept as a row, since the parser +often promotes the page's first data row to a header). Heuristic, but cheap and reversible. Ported verbatim from the POC `loader/table_stitch.py` (no I/O — pure markdown). """ @@ -10,13 +11,232 @@ from __future__ import annotations import re +from html.parser import HTMLParser _ROW = re.compile(r"^\s*\|.*\|\s*$") _SEP = re.compile(r"^\s*\|[\s:|-]+\|\s*$") +_CELL_SPLIT = re.compile(r"(?]*>.*?", re.IGNORECASE | re.DOTALL) +_INLINE_MARKS = {"strong": "**", "b": "**", "em": "*", "i": "*"} +_KEPT_INLINE_TAGS = ("sup", "sub") +_WRAPPER_TAGS = ("thead", "tbody", "tfoot", "span", "p", "u") +_LIST_TAGS = ("ul", "ol") +MIN_NUMBERED_ROWS = 2 + + +class _TableReader(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.rows: list[list[str]] = [] + self.header_flags: list[list[bool]] = [] + self.cell: list[str] | None = None + self.row_open = False + self.saw_row_tag = False + self.table_depth = 0 + self.convertible = True + self.list_counters: list[int | None] = [] + + def handle_starttag(self, tag, attrs): + if tag == "table": + self.table_depth += 1 + self.convertible = self.convertible and self.table_depth == 1 + elif tag == "tr": + self.close_cell() + self.saw_row_tag = True + self.start_row() + elif tag in ("td", "th"): + self.start_cell(tag, attrs) + elif tag == "br": + self.append("
") + elif tag in _INLINE_MARKS: + self.append(_INLINE_MARKS[tag]) + elif tag in _KEPT_INLINE_TAGS: + self.append(f"<{tag}>") + elif tag in (*_LIST_TAGS, "li") and self.cell is not None: + self.start_list_tag(tag) + elif tag not in _WRAPPER_TAGS: + self.convertible = False + + def handle_endtag(self, tag): + if tag in ("td", "th"): + self.close_cell() + elif tag == "tr": + self.close_cell() + self.row_open = False + elif tag in _INLINE_MARKS: + self.append(_INLINE_MARKS[tag]) + elif tag in _KEPT_INLINE_TAGS: + self.append(f"") + elif tag in _LIST_TAGS and self.list_counters: + self.list_counters.pop() + + def handle_data(self, data): + if self.cell is not None: + self.cell.append(data.replace("<", "<")) + elif data.strip(): + self.convertible = False + + def append(self, text: str): + if self.cell is not None: + self.cell.append(text) + + def start_cell(self, tag: str, attrs): + if any(name in ("rowspan", "colspan") and (value or "1").strip() != "1" for name, value in attrs): + self.convertible = False + self.close_cell() + if not self.row_open: + self.start_row() + self.cell = [] + self.header_flags[-1].append(tag == "th") + + def start_list_tag(self, tag: str): + if tag in _LIST_TAGS: + self.list_counters.append(0 if tag == "ol" else None) + elif self.list_counters: + self.start_list_item() + + def start_list_item(self): + counter = self.list_counters[-1] + if counter is None: + marker = "•" + else: + self.list_counters[-1] = counter = counter + 1 + marker = f"{counter}." + text = "".join(self.cell or []).rstrip() + self.cell = [f"{text}
{marker} " if text else f"{marker} "] + + def start_row(self): + self.rows.append([]) + self.header_flags.append([]) + self.row_open = True + + def close_cell(self): + if self.cell is None: + return + text = " ".join("".join(self.cell).split()) + self.rows[-1].append(text.replace("|", "\\|")) + self.cell = None + + +def _format_row(cells: list[str]) -> str: + return "| " + " | ".join(cells) + " |" + + +def _html_table_to_markdown(html: str) -> str | None: + reader = _TableReader() + reader.feed(html) + reader.close() + reader.close_cell() + if not reader.convertible: + return None + rows, header_flags = reader.rows, reader.header_flags + if not reader.saw_row_tag: + columns = sum(header_flags[0]) if header_flags else 0 + if not columns: + return None + cells = rows[0] + rows = [cells[start : start + columns] for start in range(0, len(cells), columns)] + header_flags = [[index < columns for index in range(len(row))] for row in rows] + rows_with_flags = [(row, flags) for row, flags in zip(rows, header_flags, strict=True) if row] + if not rows_with_flags: + return None + columns = max(len(row) for row, _ in rows_with_flags) + padded = [row + [""] * (columns - len(row)) for row, _ in rows_with_flags] + if all(rows_with_flags[0][1]): + header, body = padded[0], padded[1:] + else: + header, body = [""] * columns, padded + return "\n".join([_format_row(header), "|" + "---|" * columns, *(_format_row(row) for row in body)]) + + +def html_tables_to_markdown(md: str) -> str: + """Rewrite flat HTML tables (no merged cells, inline content only) as pipe tables, repairing + unclosed cells/rows on the way, so they can be stitched and cleaned like any other table.""" + + def replace(match: re.Match) -> str: + converted = _html_table_to_markdown(match.group(0)) + if not converted: + return match.group(0) + before, after = md[: match.start()], md[match.end() :] + prefix = "" if not before or before.endswith("\n\n") else "\n" * (1 + (not before.endswith("\n"))) + suffix = "" if not after or after.startswith("\n\n") else "\n" * (1 + (not after.startswith("\n"))) + return f"{prefix}{converted}{suffix}" + + return _HTML_TABLE.sub(replace, md) + + +def _cells(row: str) -> list[str]: + text = row.strip() + text = text[1:] if text.startswith("|") else text + text = text[:-1] if text.endswith("|") and not text.endswith("\\|") else text + return [cell.strip() for cell in _CELL_SPLIT.split(text)] def _ncols(row: str) -> int: - return len(row.strip().strip("|").split("|")) + return len(_cells(row)) + + +def _plain_cell(cell: str) -> str: + return _EMPHASIS.sub("", cell).strip() + + +def _header_key(row: str) -> str: + return " ".join(_plain_cell(cell).lower() for cell in _cells(row)).strip() + + +def _is_numbered(rows: list[str]) -> bool: + return sum(1 for row in rows if _STEP.match(_plain_cell(_cells(row)[0]))) >= MIN_NUMBERED_ROWS + + +def _continues(row: str, after_page_break: bool) -> bool: + """A row that carries on the previous step instead of starting one: no step number and, in the + first non-empty cell, a lowercase start (or, right after a page break, any non-capital start).""" + cells = [_plain_cell(cell) for cell in _cells(row)] + if _STEP.match(cells[0]): + return False + if after_page_break: + text = next((cell for cell in cells if cell), "") + return bool(text) and not text[0].isupper() + return bool(cells[0]) and cells[0][0].islower() + + +def _merge_rows(previous: str, row: str) -> str: + merged = [ + " ".join(part for part in (first, second) if part) + for first, second in zip(_cells(previous), _cells(row), strict=False) + ] + return _format_row(merged) + + +def merge_continuation_rows(md: str) -> str: + """Fold a row a page break cut off (lowercase, unnumbered) back into the step it continues.""" + lines = md.split("\n") + out: list[str] = [] + index = 0 + while index < len(lines): + if not _ROW.match(lines[index]): + out.append(lines[index]) + index += 1 + continue + end = index + while end < len(lines) and _ROW.match(lines[end]): + end += 1 + block = lines[index:end] + body_start = 2 if len(block) > 1 and _SEP.match(block[1]) else 0 + rows = block[:body_start] + if _is_numbered(block[body_start:]): + for row in block[body_start:]: + if len(rows) > body_start and _ncols(row) == _ncols(rows[-1]) and _continues(row, False): + rows[-1] = _merge_rows(rows[-1], row) + else: + rows.append(row) + else: + rows = block + out.extend(rows) + index = end + return "\n".join(out) def _trailing_table(md: str): @@ -39,6 +259,29 @@ def _leading_table(md: str): return lines[:j], lines[j:] # (table_lines, after) +def _empty_column(table: list[str]) -> int | None: + """The first column with no text in any body row: the column a parser drops when a table goes on + over a page break, since nothing on the continuation page shows it is there.""" + body = table[2:] if len(table) > 1 and _SEP.match(table[1]) else table + rows = [_cells(row) for row in body] + if not rows: + return None + return next( + ( + column + for column in range(_ncols(table[0])) + if not any(_plain_cell(row[column]) for row in rows if column < len(row)) + ), + None, + ) + + +def _with_blank_cell(row: str, column: int) -> str: + cells = _cells(row) + cells.insert(column, "") + return _format_row(cells) + + def stitch_cross_page_tables(pages: list[tuple[int, str]]) -> list[tuple[int, str]]: out = [[pno, md] for pno, md in pages] for k in range(len(out) - 1): @@ -48,10 +291,20 @@ def stitch_cross_page_tables(pages: list[tuple[int, str]]) -> list[tuple[int, st continue a_before, a_tbl = a b_tbl, b_after = b - if not a_tbl or not b_tbl or _ncols(a_tbl[0]) != _ncols(b_tbl[0]): + if not a_tbl or not b_tbl: + continue + dropped_column = _empty_column(a_tbl) if _ncols(b_tbl[0]) == _ncols(a_tbl[0]) - 1 else None + if _ncols(a_tbl[0]) != _ncols(b_tbl[0]) and dropped_column is None: continue - # Drop a repeated header+separator at the start of the continuation table. - cont = b_tbl[2:] if len(b_tbl) >= 2 and _SEP.match(b_tbl[1]) else b_tbl + cont = b_tbl + if len(b_tbl) >= 2 and _SEP.match(b_tbl[1]): + repeated = _header_key(b_tbl[0]) in ("", _header_key(a_tbl[0])) + cont = b_tbl[2:] if repeated else [b_tbl[0], *b_tbl[2:]] + if dropped_column is not None: + cont = [_with_blank_cell(row, dropped_column) for row in cont] + if cont and not _SEP.match(a_tbl[-1]) and _is_numbered(a_tbl) and _continues(cont[0], True): + a_tbl = [*a_tbl[:-1], _merge_rows(a_tbl[-1], cont[0])] + cont = cont[1:] out[k][1] = "\n".join(a_before + a_tbl + cont).strip() out[k + 1][1] = "\n".join(b_after).strip() return [(pno, md) for pno, md in out] diff --git a/wikify/engine/parsers/vlm.py b/wikify/engine/parsers/vlm.py index eac3a20..4764490 100644 --- a/wikify/engine/parsers/vlm.py +++ b/wikify/engine/parsers/vlm.py @@ -31,6 +31,10 @@ 'and statutory references (e.g. "u/s 115BAC", "section 44AD", "First Proviso") character ' "for character. Never round, re-word, convert or summarise a number, and never invent " "content.\n" + "Forms keep their furniture: write every checkbox as ☐ (empty) or ☑ (ticked) in front of its " + "option, and keep blank fill-in lines as runs of underscores.\n" + "Keep list markers as printed: letters (a. b. c.), roman numerals (i. ii.) and ✓ ticks are " + "never renumbered into 1. 2. 3.\n" "Output only the Markdown — no commentary, and no code fences except ```mermaid." ) @@ -41,6 +45,7 @@ def parse_page_image( project_context: str = "", instruction: str = "", shape_hint: str = "", + figure_hint: str = "", ) -> str: preamble = context_block(project_context) + instruction_block(instruction) + shape_hint resp = llm.chat_completion( @@ -49,7 +54,7 @@ def parse_page_image( { "role": "user", "content": [ - {"type": "text", "text": preamble + _PROMPT}, + {"type": "text", "text": preamble + _PROMPT + figure_hint}, {"type": "image_url", "image_url": {"url": image_data_url}}, ], } diff --git a/wikify/engine/remediate.py b/wikify/engine/remediate.py index 9059306..43b23ac 100644 --- a/wikify/engine/remediate.py +++ b/wikify/engine/remediate.py @@ -5,9 +5,8 @@ import fitz -from wikify.engine import diagrams, llm, pdf_utils, regions, settings, store +from wikify.engine import diagrams, images, llm, pdf_utils, regions, settings, store from wikify.engine.loader.cleanup_llm import clean_markdown -from wikify.engine.loader.table_stitch import stitch_cross_page_tables from wikify.engine.parsers import vlm from wikify.engine.sectionize import rebuild_and_classify from wikify.engine.verify import deterministic as det @@ -93,6 +92,7 @@ def remediate_pdf( # noqa: C901 with fitz.open(pdf_path) as doc: furniture = det.find_furniture_lines([doc[p["page_no"] - 1].get_text("text") for p in pages]) + repeated = images.repeated_images(doc) doc_cost = 0.0 for i, p in enumerate(targets): @@ -102,6 +102,7 @@ def remediate_pdf( # noqa: C901 page_image = p["image"] or "" data_url = pdf_utils.png_to_data_url(pdf_utils.render_png(page, dpi=dpi)) page_regions = regions.find_regions(page) + figures = images.page_figures(page, repeated, p["name"], p["page_no"]) use_judge = judge_all or kind == "visual" img = data_url if use_judge else None @@ -122,7 +123,9 @@ def remediate_pdf( # noqa: C901 project_context=project_context, instruction=instruction, shape_hint=regions.shape_hint(page_regions), + figure_hint=images.figure_hint(figures), ) + vlm_md = images.place_figures(vlm_md, figures, p["page_no"]) vlm_md, diagram_notes = diagrams.remove_unverified_diagrams(vlm_md, page_image) errors.extend(diagram_notes) vlm_ps = score_page( @@ -134,7 +137,11 @@ def remediate_pdf( # noqa: C901 if kind != "visual": try: clean_md, clean_notes = diagrams.remove_unverified_diagrams( - clean_markdown(base_md, project_context=project_context, instruction=instruction), + images.place_figures( + clean_markdown(base_md, project_context=project_context, instruction=instruction), + figures, + p["page_no"], + ), page_image, ) errors.extend(clean_notes) @@ -175,6 +182,7 @@ def remediate_pdf( # noqa: C901 canon_src[p["page_no"]] = method else: canon_md[p["page_no"]] = base_md + canon_md[p["page_no"]] = images.place_figures(canon_md[p["page_no"]], figures, p["page_no"]) canon_md[p["page_no"]] = repair_broken_image_tags(canon_md[p["page_no"]], page_image) canon_md[p["page_no"]] = with_page_crop(canon_md[p["page_no"]], page_image) @@ -193,11 +201,10 @@ def remediate_pdf( # noqa: C901 if progress_cb: progress_cb(i + 1, total) - stitched = dict(stitch_cross_page_tables([(p["page_no"], canon_md[p["page_no"]]) for p in pages])) with events.suspended_indexing(): for p in pages: pno = p["page_no"] - store.set_canonical(p["name"], stitched[pno], canon_comp[pno], canon_src[pno]) + store.set_canonical(p["name"], canon_md[pno], canon_comp[pno], canon_src[pno]) comps = [c for c in canon_comp.values() if c is not None] canonical_mean = round(sum(comps) / len(comps), 3) if comps else None diff --git a/wikify/engine/reparse.py b/wikify/engine/reparse.py index 4b80f51..6b6c430 100644 --- a/wikify/engine/reparse.py +++ b/wikify/engine/reparse.py @@ -2,7 +2,7 @@ import fitz -from wikify.engine import diagrams, llm, pdf_utils, regions, remediate, settings, store +from wikify.engine import diagrams, images, llm, pdf_utils, regions, remediate, settings, store from wikify.engine.loader.cleanup_llm import clean_markdown from wikify.engine.parsers import vlm from wikify.engine.tags import find_tag_spans @@ -36,6 +36,7 @@ def reparse_page( fpage = doc[page_no - 1] gt = fpage.get_text("text") shape_hint = regions.shape_hint(regions.find_regions(fpage)) + figures = images.page_figures(fpage, images.repeated_images(doc), page["name"], page_no) data_url = pdf_utils.png_to_data_url(pdf_utils.render_png(fpage, dpi=dpi)) kind = page["kind"] @@ -47,11 +48,16 @@ def reparse_page( llm.reset_metrics() new_md = ( vlm.parse_page_image( - data_url, project_context=project_context, instruction=instruction, shape_hint=shape_hint + data_url, + project_context=project_context, + instruction=instruction, + shape_hint=shape_hint, + figure_hint=images.figure_hint(figures), ) if method == "vlm" else clean_markdown(base_md, project_context=project_context, instruction=instruction) ) + new_md = images.place_figures(new_md, figures, page_no) page_image = store.get_page_image(page["name"]) or "" new_md, diagram_notes = diagrams.remove_unverified_diagrams(new_md, page_image) new_md = remediate.repair_broken_image_tags(new_md, page_image) diff --git a/wikify/engine/sectionize.py b/wikify/engine/sectionize.py index 7473e5d..12ad56d 100644 --- a/wikify/engine/sectionize.py +++ b/wikify/engine/sectionize.py @@ -20,23 +20,28 @@ import frappe -from wikify.engine import store +from wikify.engine import images, llm, store from wikify.engine.classify import classify_document -from wikify.engine.lint import fix_table_separators +from wikify.engine.lint import repair_markdown from wikify.engine.loader.cleanup import clean_pages +from wikify.engine.loader.page_plan import plan_pages from wikify.engine.loader.sectionizer import _HEADING_RE, _clean_title, sectionize from wikify.engine.loader.toc import toc_level_map -def sectionize_document(source_document: str, pdf_path: str) -> int: +def sectionize_document(source_document: str, pdf_path: str, project_context: str = "") -> int: """Rebuild the Source Section tree from the doc's canonical pages. Returns the count.""" level_map = toc_level_map(str(pdf_path)) - pages = clean_pages(store.get_canonical_pages(source_document)) - sections = sectionize(pages, level_map) - # 0.6 auto-fix boundary: repair separator-less tables in the assembled section + pages = clean_pages(images.drop_figure_echoes(store.get_canonical_pages(source_document), pdf_path)) + sections = plan_pages( + sectionize(pages, level_map), + project_context, + use_llm=llm.has_openrouter() and not frappe.flags.in_test, + ) + # 0.6 auto-fix boundary: mechanical markdown repairs in the assembled section # product, pre-review. Page canonical stays untouched — pages are evidence. for sec in sections: - sec.markdown = fix_table_separators(sec.markdown) + sec.markdown = repair_markdown(sec.markdown) return store.replace_sections(source_document, sections) @@ -55,7 +60,7 @@ def rebuild_and_classify( """ if stage_cb: stage_cb("Building section tree") - count = sectionize_document(source_document, pdf_path) + count = sectionize_document(source_document, pdf_path, project_context) if stage_cb: stage_cb("Classifying sections") classify_document( @@ -153,7 +158,7 @@ def rebuild_section_markdown(section_name: str) -> dict: raise ValueError(f"No pages found in range {sec.page_start}-{sec.page_end}.") cleaned = clean_pages(pages) - markdown = fix_table_separators("\n\n".join(md.strip() for _, md in cleaned if md.strip())) + markdown = repair_markdown("\n\n".join(md.strip() for _, md in cleaned if md.strip())) first_line, _, body = markdown.partition("\n") heading = _HEADING_RE.match(first_line) if heading and _clean_title(heading.group(2)) == sec.title: diff --git a/wikify/engine/store.py b/wikify/engine/store.py index 36b28c2..5e55ee0 100644 --- a/wikify/engine/store.py +++ b/wikify/engine/store.py @@ -1,7 +1,7 @@ from __future__ import annotations import frappe -from frappe.utils.file_manager import save_file +from frappe.utils.file_manager import get_content_hash, save_file from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_random from wikify.engine.verify.harness import get_verdict @@ -51,6 +51,10 @@ def add_page( return page.name +def set_baseline_markdown(page_name: str, markdown: str) -> None: + frappe.db.set_value("Source Page", page_name, "baseline_markdown", markdown) + + def set_page_count(source_document: str, count: int) -> None: frappe.db.set_value("Source Document", source_document, "page_count", count) @@ -166,6 +170,17 @@ def get_page(source_document: str, page_no: int) -> dict | None: def save_crop_file(page_name: str, page_no: int, png_bytes: bytes): + existing = frappe.db.get_value( + "File", + { + "attached_to_doctype": "Source Page", + "attached_to_name": page_name, + "content_hash": get_content_hash(png_bytes), + }, + "name", + ) + if existing: + return frappe.get_doc("File", existing) return save_file(f"page-{page_no:04d}-crop.png", png_bytes, "Source Page", page_name, is_private=1) diff --git a/wikify/tests/test_generate.py b/wikify/tests/test_generate.py index b77264a..2d8e68e 100644 --- a/wikify/tests/test_generate.py +++ b/wikify/tests/test_generate.py @@ -6,10 +6,11 @@ import frappe from frappe.tests.utils import FrappeTestCase from frappe.utils.nestedset import get_descendants_of +from wiki.frappe_wiki.doctype.wiki_document.wiki_document import get_landing_page_for_route from wikify.api import imports as imports_api from wikify.engine import generate_wiki, preview_wiki, store -from wikify.engine.generate import DATA_MAX_LENGTH +from wikify.engine.generate import DATA_MAX_LENGTH, sync_section from wikify.engine.loader.sectionizer import Section from wikify.engine.loader.wiki import rewrite_page_refs, slugify from wikify.jobs import generate as generate_job @@ -205,6 +206,66 @@ def test_regenerate_after_rename_updates_route(self): self.assertIn("1.1 Goals", docs) self.assertTrue(docs["1.1 Goals"].route.endswith(f"{slugify('1.1 Goals')}-{purpose}")) + def test_group_body_shows_as_its_first_page(self): + res = self._generate() + docs = self._by_title(res["root_group"]) + intro, overview = docs["1. Intro"], docs["Overview"] + self.assertEqual(intro.content, "") + self.assertEqual(overview.is_group, 0) + self.assertEqual(overview.parent_wiki_document, intro.name) + self.assertEqual(overview.content, "overview") + self.assertLess(overview.sort_order, docs["1.1 Purpose"].sort_order) + self.assertEqual(get_landing_page_for_route(intro.route)["route"], overview.route) + + intro_section = frappe.db.get_value( + "Source Section", {"title": "1. Intro", "source_document": self.sd.name}, "name" + ) + frappe.db.set_value("Source Section", intro_section, "markdown", "edited overview") + self.assertTrue(sync_section(intro_section)["synced"]) + self.assertEqual(frappe.db.get_value("Wiki Document", overview.name, "content"), "edited overview") + + def test_regenerate_keeps_the_overview_and_drops_it_once_the_group_body_is_gone(self): + res = self._generate() + overview = self._by_title(res["root_group"])["Overview"].name + generate_wiki(self.sd.name, wiki_space=res["space"]) + self.assertEqual(self._by_title(res["root_group"])["Overview"].name, overview) + + frappe.db.set_value( + "Source Section", {"title": "1. Intro", "source_document": self.sd.name}, "markdown", "" + ) + generate_wiki(self.sd.name, wiki_space=res["space"]) + docs = self._by_title(res["root_group"]) + self.assertNotIn("Overview", docs) + self.assertIn("## Contents", docs["1. Intro"].content) + + def test_contents_page_is_built_when_the_pdf_prints_none(self): + res = self._generate() + docs = self._by_title(res["root_group"]) + contents = docs["Contents"] + self.assertEqual(contents.is_group, 0) + self.assertEqual(contents.parent_wiki_document, res["root_group"]) + self.assertLess(contents.sort_order, docs["1. Intro"].sort_order) + self.assertIn(f"- [1. Intro](/{docs['1. Intro'].route}) — pp. 1-3", contents.content) + self.assertIn(f" - [1.2 Scope](/{docs['1.2 Scope'].route}) — p. 2", contents.content) + self.assertEqual(get_landing_page_for_route(docs["Gen Test"].route)["route"], contents.route) + + generate_wiki(self.sd.name, wiki_space=res["space"]) + self.assertEqual(self._by_title(res["root_group"])["Contents"].name, contents.name) + + def test_printed_contents_replaces_the_built_one(self): + res = self._generate() + store.replace_sections( + self.sd.name, + [ + _sec("Contents", 1, ["Contents"], 1, 1, "1. Intro .... 2"), + _sec("1. Intro", 1, ["1. Intro"], 2, 5), + ], + ) + generate_wiki(self.sd.name, wiki_space=res["space"]) + docs = self._by_title(res["root_group"]) + self.assertEqual(docs["Contents"].content, "1. Intro .... 2") + self.assertEqual(len(docs), 3) + def test_data_max_length_matches_the_column_width(self): self.assertEqual(DATA_MAX_LENGTH, frappe.db.VARCHAR_LEN) @@ -216,7 +277,7 @@ def test_deep_tree_with_long_titles_stays_within_route_limit(self): res = self._generate() docs = self._docs_under(res["root_group"]) - self.assertEqual(len(docs), len(titles) + 1) + self.assertEqual(len(docs), 2 * len(titles) + 1) self.assertTrue(all(len(doc.route) <= DATA_MAX_LENGTH for doc in docs.values())) self.assertEqual(len({doc.route for doc in docs.values()}), len(docs)) diff --git a/wikify/tests/test_images.py b/wikify/tests/test_images.py new file mode 100644 index 0000000..0307b04 --- /dev/null +++ b/wikify/tests/test_images.py @@ -0,0 +1,266 @@ +# Copyright (c) 2026, BWH and contributors +# For license information, please see license.txt + +import tempfile +from pathlib import Path +from unittest.mock import patch + +import fitz +import frappe +from frappe.tests.utils import FrappeTestCase + +from wikify.engine import images, parse_pdf, remediate_pdf +from wikify.engine import settings as engine_settings +from wikify.engine.loader.cleanup import drop_transcribed_figures +from wikify.tests import _cleanup +from wikify.tests.test_remediate_pipeline import _fake_chat + +CHART_RECT = fitz.Rect(100, 300, 400, 500) +TEXT_BEFORE = "Admissions rose steadily over the decade." +TEXT_AFTER = "Discharge planning starts on the first day." + + +def solid_pixmap(width: int, height: int, colour: tuple[int, int, int]) -> fitz.Pixmap: + pixmap = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, width, height), False) + pixmap.set_rect(pixmap.irect, colour) + return pixmap + + +def make_report_pdf(path: str) -> None: + logo = solid_pixmap(80, 80, (20, 40, 160)) + chart = solid_pixmap(600, 400, (200, 60, 30)) + document = fitz.open() + for page_no in range(1, 4): + page = document.new_page(width=612, height=792) + page.insert_image(fitz.Rect(72, 100, 152, 180), pixmap=logo) + page.insert_text((72, 230), f"Annual report section {page_no}", fontsize=14) + if page_no == 2: + page.insert_text((100, 280), TEXT_BEFORE, fontsize=11) + page.insert_image(CHART_RECT, pixmap=chart) + page.insert_text((100, 540), TEXT_AFTER, fontsize=11) + else: + page.insert_text((72, 280), "Plain text page with no pictures at all.", fontsize=11) + document.save(path) + document.close() + + +def chart_figure(**values) -> images.Figure: + return images.Figure( + bbox=tuple(CHART_RECT), + page_size=(612.0, 792.0), + text_before=TEXT_BEFORE, + text_after=TEXT_AFTER, + url="/private/files/chart.png", + **values, + ) + + +class TestFigureDetection(FrappeTestCase): + def test_finds_the_body_chart_and_skips_the_logo_repeated_on_every_page(self): + path = Path(tempfile.mkdtemp()) / "report.pdf" + make_report_pdf(str(path)) + with fitz.open(str(path)) as document: + repeated = images.repeated_images(document) + found = [images.find_figures(page, repeated) for page in document] + + self.assertEqual([len(figures) for figures in found], [0, 1, 0]) + chart = found[1][0] + self.assertEqual(fitz.Rect(chart.bbox), CHART_RECT) + self.assertEqual(chart.text_before, TEXT_BEFORE) + self.assertEqual(chart.text_after, TEXT_AFTER) + + def test_the_crop_leaves_out_a_title_line_its_padding_would_cut_in_half(self): + document = fitz.open() + page = document.new_page(width=612, height=792) + page.insert_text( + (100, CHART_RECT.y0 - 2), "DOSES OF IP ANTIBIOTICS (ISPD 2016 GUIDELINES)", fontsize=11 + ) + page.insert_image(CHART_RECT, pixmap=solid_pixmap(600, 400, (200, 60, 30))) + title_bottom = images.text_line_boxes(page)[0][3] + + clip = images.figure_clip(page, images.find_figures(page, set())[0]) + + self.assertGreater(title_bottom, CHART_RECT.y0 - images.CROP_PADDING) + self.assertEqual(clip.y0, title_bottom) + self.assertEqual(clip.y1, CHART_RECT.y1 + images.CROP_PADDING) + + +class TestFigurePlacement(FrappeTestCase): + def test_places_the_figure_after_the_text_that_precedes_it(self): + markdown = f"# Annual report\n\n{TEXT_BEFORE}\n\n## Discharge\n\n{TEXT_AFTER}" + + placed = images.place_figures(markdown, [chart_figure()], 2) + + self.assertEqual( + placed, + f"# Annual report\n\n{TEXT_BEFORE}\n\n![Figure 2.1](/private/files/chart.png)\n\n" + f"## Discharge\n\n{TEXT_AFTER}", + ) + + def test_a_figure_after_a_table_row_lands_below_the_whole_table(self): + markdown = f"\n\n\n
{TEXT_BEFORE}
Second row
\n\nClosing note." + + placed = images.place_figures(markdown, [chart_figure()], 2) + + self.assertTrue(placed.index("![Figure 2.1]") > placed.index("")) + self.assertTrue(placed.index("![Figure 2.1]") < placed.index("Closing note.")) + + def test_swaps_the_vlm_token_for_the_crop_titled_by_the_vlm(self): + markdown = f"{TEXT_BEFORE}\n\n[[FIGURE 1: Admissions per year]]\n\n[[FIGURE 2]]\n\n{TEXT_AFTER}" + + placed = images.place_figures(markdown, [chart_figure()], 2) + + self.assertEqual( + placed, f"{TEXT_BEFORE}\n\n![Admissions per year](/private/files/chart.png)\n\n{TEXT_AFTER}" + ) + + def test_a_printed_caption_wins_over_the_vlm_title(self): + placed = images.place_figures( + "[[FIGURE 1: Bar chart]]", [chart_figure(caption="Figure 3: Admissions")], 2 + ) + + self.assertEqual(placed, "![Figure 3: Admissions](/private/files/chart.png)") + + def test_inserts_a_figure_the_vlm_left_out_after_its_preceding_text(self): + markdown = f"## Admissions\n\n{TEXT_BEFORE}\n\n{TEXT_AFTER}" + + placed = images.place_figures(markdown, [chart_figure()], 2) + + self.assertEqual( + placed, + f"## Admissions\n\n{TEXT_BEFORE}\n\n![Figure 2.1](/private/files/chart.png)\n\n{TEXT_AFTER}", + ) + + def test_a_figure_already_in_the_markdown_is_not_added_twice(self): + markdown = f"{TEXT_BEFORE}\n\n![Admissions per year](/private/files/chart.png)\n\n{TEXT_AFTER}" + + self.assertEqual(images.place_figures(markdown, [chart_figure()], 2), markdown) + + +class TestTranscribedFigures(FrappeTestCase): + def test_keeps_the_crop_of_a_redrawn_chart_and_the_typed_copy_of_a_table_image(self): + org_chart = ( + "![Figure 28.1](/private/files/page-0028-crop.png)\n\n" + '```mermaid\nflowchart TD\nA["Head of the Department"]\nB["Clinical"]\nA --> B\n```\n\n28' + ) + table_image = ( + "## DOSES OF IP ANTIBIOTICS\n\n![Figure 251.1](/private/files/page-0251-crop.png)\n\n" + "**TABLE 5**\nIntraperitoneal Antibiotic Dosing Recommendations\n\n" + "| | Intermittent | Continuous |\n|---|---|---|\n" + "| Amikacin | 2 mg/kg daily | LD 25 mg/L |\n| Gentamicin | 0.6 mg/kg daily | LD 8 mg/L |\n" + "| Cefazolin | 15 mg/kg daily | LD 500 mg/L |" + ) + + self.assertEqual( + drop_transcribed_figures(org_chart), "![Figure 28.1](/private/files/page-0028-crop.png)\n\n\n28" + ) + self.assertEqual( + drop_transcribed_figures(table_image), + "## DOSES OF IP ANTIBIOTICS\n\n\n**TABLE 5**\nIntraperitoneal Antibiotic Dosing Recommendations\n\n" + "| | Intermittent | Continuous |\n|---|---|---|\n" + "| Amikacin | 2 mg/kg daily | LD 25 mg/L |\n| Gentamicin | 0.6 mg/kg daily | LD 8 mg/L |\n" + "| Cefazolin | 15 mg/kg daily | LD 500 mg/L |", + ) + + def test_keeps_a_chart_followed_by_a_paragraph_and_a_table(self): + markdown = ( + "![Figure 3: Admissions](/private/files/chart.png)\n\n" + f"{TEXT_BEFORE} {TEXT_AFTER} {TEXT_BEFORE} {TEXT_AFTER}\n\n" + "| Year | Admissions |\n|---|---|\n| 2019 | 40 |\n| 2020 | 52 |\n| 2021 | 61 |" + ) + + self.assertEqual(drop_transcribed_figures(markdown), markdown) + + +class TestFigureEchoes(FrappeTestCase): + def test_drops_labels_read_off_the_picture_and_keeps_the_printed_text(self): + body = [ + "Figure 2 . Anatomy of lumbar spine", + "Prep and drape the area after identifying landmarks. Use lidocaine 1% with or without", + "epinephrine to anesthetize the skin and the deeper tissues under the insertion site", + "Assemble needle and manometer. Attach the 3-way stopcock to manometer", + ] + path = Path(tempfile.mkdtemp()) / "procedure.pdf" + document = fitz.open() + page = document.new_page(width=612, height=792) + page.insert_image(CHART_RECT, pixmap=solid_pixmap(600, 400, (200, 60, 30))) + for offset, line in enumerate(body): + page.insert_text((72, 530 + 16 * offset), line, fontsize=10) + document.save(str(path)) + markdown = ( + "![Figure 2 . Anatomy of lumbar spine](/private/files/page-0348-crop.png)\n\n" + "Puncture site (L4-5 interspace)\n\nLevel of posterior superior iliac crests\n\nL5 L4 L3\n\n" + "Figure 2 . Anatomy of lumbar spine\n\n" + "Prep and drape the area after identifying landmarks. Use lidocaine 1% with or without epinephrine " + "to anesthetize the skin and the deeper tissues under the insertion site\n\n" + "Assemble needle and manometer. Attach the 3-way stopcock to manometer" + ) + + [(_, cleaned)] = images.drop_figure_echoes([(1, markdown)], str(path)) + + self.assertEqual( + cleaned, + "![Figure 2 . Anatomy of lumbar spine](/private/files/page-0348-crop.png)\n\n" + "Figure 2 . Anatomy of lumbar spine\n\n" + "Prep and drape the area after identifying landmarks. Use lidocaine 1% with or without epinephrine " + "to anesthetize the skin and the deeper tissues under the insertion site\n\n" + "Assemble needle and manometer. Attach the 3-way stopcock to manometer", + ) + + +class TestFigureRemediation(FrappeTestCase): + def setUp(self): + real_get = engine_settings.get + patcher = patch( + "wikify.engine.settings.get", + side_effect=lambda field: 0 if field == "judge_all_pages" else real_get(field), + ) + patcher.start() + self.addCleanup(patcher.stop) + + def test_remediation_keeps_the_chart_as_its_crop_instead_of_a_transcription(self): + path = Path(tempfile.mkdtemp()) / "report.pdf" + make_report_pdf(str(path)) + vlm_markdown = f"{TEXT_BEFORE}\n\n[[FIGURE 1: Admissions per year]]\n\n{TEXT_AFTER}" + with ( + patch("wikify.engine.llm.has_openrouter", return_value=True), + patch("wikify.engine.llm.chat_completion", side_effect=_fake_chat), + ): + source_document = parse_pdf(str(path), title="Figure Remediation Test") + self.addCleanup(_cleanup.delete_document, source_document) + with ( + patch("wikify.engine.remediate.clean_markdown", side_effect=RuntimeError("offline")), + patch( + "wikify.engine.remediate.vlm.parse_page_image", return_value=vlm_markdown + ) as parse_page_image, + ): + remediate_pdf(source_document, str(path), scope="all") + + hints = [call.kwargs["figure_hint"] for call in parse_page_image.call_args_list] + self.assertEqual([bool(hint) for hint in hints], [False, True, False]) + self.assertIn("this page has 1 picture(s)", hints[1]) + self.assertIn("[[FIGURE 1]]", hints[1]) + self.assertIn("Never turn its bars, lines, axis ticks, legend or data labels into a table", hints[1]) + + page = frappe.db.get_value( + "Source Page", + {"source_document": source_document, "page_no": 2}, + ["name", "image", "remediation_method", "remediation_markdown", "canonical_markdown"], + as_dict=True, + ) + crop_urls = frappe.get_all( + "File", + filters={ + "attached_to_doctype": "Source Page", + "attached_to_name": page.name, + "file_url": ["!=", page.image], + }, + pluck="file_url", + ) + self.assertEqual(len(crop_urls), 1) + self.assertEqual(page.remediation_method, "vlm") + self.assertEqual( + page.remediation_markdown, + f"{TEXT_BEFORE}\n\n![Admissions per year]({crop_urls[0]})\n\n{TEXT_AFTER}", + ) + self.assertEqual(page.canonical_markdown.count(crop_urls[0]), 1) diff --git a/wikify/tests/test_lint.py b/wikify/tests/test_lint.py index 1eca042..f950afc 100644 --- a/wikify/tests/test_lint.py +++ b/wikify/tests/test_lint.py @@ -17,7 +17,14 @@ from wikify.api import sections as api from wikify.engine import store -from wikify.engine.lint import fix_table_separators, lint_markdown, table_artifacts +from wikify.engine.lint import ( + escape_fill_in_blanks, + fix_glued_emphasis, + fix_table_separators, + lint_markdown, + repair_markdown, + table_artifacts, +) from wikify.engine.loader.sectionizer import Section from wikify.engine.sectionize import rebuild_section_markdown from wikify.engine.verify.deterministic import parser_artifacts @@ -83,6 +90,70 @@ def test_leaves_lone_rows_and_fenced_tables_alone(self): self.assertEqual(fix_table_separators(fenced), fenced) +class TestFormBlanks(FrappeTestCase): + def test_fill_in_blanks_are_escaped_outside_code_and_html(self): + row = "| | Replacement fluid | ______________________@_____ ___mL/h Additives_______ |" + untouched = "___\n\n`a___b`\n\nRate_____" + blank = "\\_" + fixed = escape_fill_in_blanks(f"{row}\n\n{untouched}") + self.assertEqual( + fixed, + f"| | Replacement fluid | {blank * 22}@{blank * 5} {blank * 3}mL/h Additives{blank * 7} |\n\n{untouched}", + ) + self.assertEqual(escape_fill_in_blanks(fixed), fixed) + + +class TestGluedEmphasis(FrappeTestCase): + def test_bold_glued_to_its_neighbours_is_separated(self): + md = ( + "|1. Inspect the dressing|**To**confirm the need.|\n" + "|3. Explain the procedure|Helps**to**gain co-operation.|\n" + "|8. Clean the site|**P**revents contamination.|\n" + "- **Postpheresis: H**ypokalemia, Bleeding tendencies\n" + "Saves time and improves efficiency**.**\n" + "**Date:10/11/2023 **Pg. 337\n" + "Continuous Renal Replacement Therapy Order sheet :**" + ) + self.assertEqual( + fix_glued_emphasis(md), + "|1. Inspect the dressing|**To** confirm the need.|\n" + "|3. Explain the procedure|Helps **to** gain co-operation.|\n" + "|8. Clean the site|Prevents contamination.|\n" + "- **Postpheresis:** Hypokalemia, Bleeding tendencies\n" + "Saves time and improves efficiency.\n" + "**Date:10/11/2023** Pg. 337\n" + "Continuous Renal Replacement Therapy Order sheet :", + ) + self.assertEqual( + fix_glued_emphasis("**AVF**s and **Artero Venous Fistula (AVF**) is"), + "**AVF**s and **Artero Venous Fistula (AVF**) is", + ) + + +class TestFormattingLeaks(FrappeTestCase): + def test_bold_closing_after_a_bracket_and_boxes_read_off_bullets_are_repaired(self): + indicators = ( + "PATIENT RELATED INDICATORS (TARGET)\n\n" + "1. **Haemoglobin > 11 g (**In > 80% of patients on PD)\n" + "2. **MEASUREMENT AND REPORTING OF PERITONITIS**\n" + " - ☐ Culture - negative peritonitis (< 15 % of all peritonitis episodes)\n" + " - ☐ Pre - PD peritonitis - % of total\n\n" + "- ☐ Yes ☐ No\n- ☐ Consent filed\n\n" + "- ☑ Catheter flushed\n- ☐ Dressing changed" + ) + + self.assertEqual( + repair_markdown(indicators), + "PATIENT RELATED INDICATORS (TARGET)\n\n" + "1. **Haemoglobin > 11 g** (In > 80% of patients on PD)\n" + "2. **MEASUREMENT AND REPORTING OF PERITONITIS**\n" + " - Culture - negative peritonitis (< 15 % of all peritonitis episodes)\n" + " - Pre - PD peritonitis - % of total\n\n" + "- ☐ Yes ☐ No\n- ☐ Consent filed\n\n" + "- ☑ Catheter flushed\n- ☐ Dressing changed", + ) + + class TestPageArtifacts(FrappeTestCase): def test_parser_artifacts_include_table_breakage(self): self.assertIn("table missing separator row", parser_artifacts(MISSING_SEPARATOR)) diff --git a/wikify/tests/test_page_plan.py b/wikify/tests/test_page_plan.py new file mode 100644 index 0000000..b7e82cf --- /dev/null +++ b/wikify/tests/test_page_plan.py @@ -0,0 +1,438 @@ +# Copyright (c) 2026, BWH and contributors +# For license information, please see license.txt +import json +from unittest.mock import patch + +from frappe.tests.utils import FrappeTestCase + +from wikify.engine.loader import page_plan +from wikify.engine.loader.page_plan import plan_pages +from wikify.engine.loader.sectionizer import Section + + +def _sec(path, page_start, page_end=None, markdown=None): + return Section( + title=path[-1], + level=len(path), + hierarchy_path=path, + page_start=page_start, + page_end=page_end or page_start, + markdown=markdown if markdown is not None else f"body of {path[-1]}", + ) + + +def _words(count): + return " ".join(["word"] * count) + + +def _outline(): + return [ + _sec(["Preamble"], 1, markdown=_words(60)), + _sec(["5.8 Care"], 2, markdown=""), + _sec(["5.8 Care", "5.8.1 Chaperone"], 2), + _sec(["5.8 Care", "5.8.4 Sedation"], 3), + _sec(["5.8 Care", "5.8.4 Sedation", "5.8.4.1 Indications"], 4), + _sec(["5.8 Care", "5.8.4 Sedation", "5.8.4.1 Indications", "Contraindications"], 4), + _sec(["5.8 Care", "5.8.4 Sedation", "5.8.4.2 Consents"], 5, 6), + _sec(["5.8 Care", "5.8.9 Grievances"], 7, markdown=_words(30)), + _sec(["5.8 Care", "5.8.9 Grievances", "1. Drug Regimen"], 7), + _sec(["6.1 Protocols"], 8, markdown=""), + _sec(["6.1 Protocols", "6.1.2 Lupus nephritis"], 8), + _sec(["6.1 Protocols", "6.1.2 Lupus nephritis", "Class IV"], 9, markdown=""), + _sec(["6.1 Protocols", "6.1.2 Lupus nephritis", "Class V"], 10), + ] + + +def _llm_reply(payload): + content = payload if isinstance(payload, str) else json.dumps(payload) + return {"choices": [{"message": {"content": content}}]} + + +def _titles(sections): + return [section.title for section in sections] + + +class TestFallbackPagePlan(FrappeTestCase): + def test_pages_are_the_top_two_numbered_levels_below_the_chapter(self): + pages = plan_pages(_outline()) + self.assertEqual( + _titles(pages), + [ + "Preamble", + "5.8 Care", + "5.8.1 Chaperone", + "5.8.4 Sedation", + "5.8.9 Grievances", + "6.1 Protocols", + "6.1.2 Lupus nephritis", + ], + ) + + def test_unnumbered_document_keeps_its_top_two_heading_levels(self): + sections = [ + _sec(["Guide"], 1), + _sec(["Guide", "Setup"], 2), + _sec(["Guide", "Setup", "Prerequisites"], 3), + _sec(["Guide", "Usage"], 4), + ] + self.assertEqual(_titles(plan_pages(sections)), ["Guide", "Setup", "Usage"]) + + def test_a_lone_sub_section_folds_into_its_parent(self): + sections = [ + _sec(["5.7 Billing"], 1, markdown=""), + _sec(["5.7 Billing", "5.7.2 Inpatient billing"], 1, 2), + _sec(["5.8 Care"], 2), + _sec(["5.8 Care", "5.8.1 Chaperone"], 3), + _sec(["5.8 Care", "5.8.1 Chaperone", "5.8.1.1 Escort"], 4), + ] + pages = plan_pages(sections) + self.assertEqual(_titles(pages), ["5.7 Billing", "5.8 Care", "5.8.1 Chaperone"]) + self.assertEqual(pages[0].markdown, "## 5.7.2 Inpatient billing\n\nbody of 5.7.2 Inpatient billing") + + def test_folded_sections_become_headings_in_document_order(self): + sedation = plan_pages(_outline())[3] + self.assertEqual( + sedation.markdown, + "body of 5.8.4 Sedation\n\n" + "## 5.8.4.1 Indications\n\nbody of 5.8.4.1 Indications\n\n" + "### Contraindications\n\nbody of Contraindications\n\n" + "## 5.8.4.2 Consents\n\nbody of 5.8.4.2 Consents", + ) + self.assertEqual((sedation.page_start, sedation.page_end), (3, 6)) + + def test_an_empty_folded_section_keeps_its_heading(self): + lupus = plan_pages(_outline())[-1] + self.assertEqual( + lupus.markdown, + "body of 6.1.2 Lupus nephritis\n\n## Class IV\n\n## Class V\n\nbody of Class V", + ) + self.assertEqual(lupus.page_end, 10) + + def test_heading_depth_is_capped_at_six(self): + path = [f"Level {depth}" for depth in range(1, 9)] + sections = [_sec(path[:depth], min(depth, 2)) for depth in range(1, 9)] + child = plan_pages(sections)[1] + self.assertEqual(child.title, "Level 2") + self.assertIn("###### Level 7\n\nbody of Level 7\n\n###### Level 8", child.markdown) + + def test_hierarchy_of_the_remaining_pages_is_unchanged(self): + pages = plan_pages(_outline()) + self.assertEqual(pages[-1].hierarchy_path, ["6.1 Protocols", "6.1.2 Lupus nephritis"]) + + +class TestLlmPagePlan(FrappeTestCase): + def _plan(self, reply, sections=None): + with patch.object(page_plan.llm, "chat_completion", return_value=reply) as chat: + pages = plan_pages(sections or _outline(), "Hospital manual", use_llm=True) + return pages, chat + + def test_llm_choice_decides_the_pages(self): + pages, chat = self._plan(_llm_reply({"pages": [2, 3, 7, 10]})) + self.assertEqual( + _titles(pages), + [ + "Preamble", + "5.8 Care", + "5.8.1 Chaperone", + "5.8.4 Sedation", + "5.8.9 Grievances", + "6.1 Protocols", + "6.1.2 Lupus nephritis", + ], + ) + self.assertEqual(chat.call_args.kwargs["label"], "page_plan") + self.assertEqual(chat.call_args.kwargs["response_format"], {"type": "json_object"}) + prompt = chat.call_args.args[1][0]["content"] + self.assertIn("Hospital manual", prompt) + self.assertIn("4 | 3 | 5.8.4.1 Indications | p4-4 | 4 | 7 | 1", prompt) + + def test_a_json_encoded_page_list_is_accepted(self): + pages, _ = self._plan(_llm_reply({"pages": "[3, 7]"})) + self.assertEqual( + _titles(pages), + [ + "Preamble", + "5.8 Care", + "5.8.1 Chaperone", + "5.8.4 Sedation", + "5.8.9 Grievances", + "6.1 Protocols", + ], + ) + + def test_top_level_sections_are_pages_even_when_the_llm_skips_them(self): + pages, _ = self._plan(_llm_reply({"pages": []})) + self.assertEqual(_titles(pages), ["Preamble", "5.8 Care", "6.1 Protocols"]) + self.assertIn("## 5.8.1 Chaperone", pages[1].markdown) + self.assertIn("### 5.8.4.1 Indications", pages[1].markdown) + + def test_a_page_under_a_folded_parent_folds_with_it(self): + pages, _ = self._plan(_llm_reply({"pages": [4, 5]})) + self.assertEqual(_titles(pages), ["Preamble", "5.8 Care", "6.1 Protocols"]) + + def test_an_empty_page_without_page_children_folds_into_its_parent(self): + pages, _ = self._plan(_llm_reply({"pages": [10, 11]})) + self.assertEqual(_titles(pages)[-2:], ["6.1 Protocols", "6.1.2 Lupus nephritis"]) + self.assertIn("## Class IV\n\n## Class V", pages[-1].markdown) + + def test_unusable_indices_are_ignored(self): + pages, _ = self._plan(_llm_reply({"pages": ["2", 99, -1, True, 3]})) + self.assertEqual( + _titles(pages), ["Preamble", "5.8 Care", "5.8.4 Sedation", "5.8.9 Grievances", "6.1 Protocols"] + ) + + def test_invalid_reply_falls_back_to_the_numbering_rule(self): + expected = _titles(plan_pages(_outline())) + for reply in (_llm_reply("not json"), _llm_reply({"pages": "all"}), _llm_reply({})): + with patch.object(page_plan.frappe, "log_error"): + pages, _ = self._plan(reply) + self.assertEqual(_titles(pages), expected) + + def test_llm_error_falls_back_to_the_numbering_rule(self): + with ( + patch.object(page_plan.llm, "chat_completion", side_effect=RuntimeError("down")), + patch.object(page_plan.frappe, "log_error") as log_error, + ): + pages = plan_pages(_outline(), use_llm=True) + self.assertEqual(_titles(pages), _titles(plan_pages(_outline()))) + log_error.assert_called_once() + + def test_large_outlines_are_sent_per_chapter(self): + replies = [_llm_reply({"pages": [2]}), _llm_reply({"pages": [10]})] + with ( + patch.object(page_plan, "OUTLINE_BATCH_LINES", 9), + patch.object(page_plan.llm, "chat_completion", side_effect=replies) as chat, + ): + pages = plan_pages(_outline(), use_llm=True) + self.assertEqual(chat.call_count, 2) + self.assertNotIn("6.1 Protocols", chat.call_args_list[0].args[1][0]["content"]) + self.assertEqual( + _titles(pages), + [ + "Preamble", + "5.8 Care", + "5.8.1 Chaperone", + "5.8.4 Sedation", + "5.8.9 Grievances", + "6.1 Protocols", + "6.1.2 Lupus nephritis", + ], + ) + + def test_an_unnumbered_top_level_fragment_folds_into_the_page_before_it(self): + sections = [ + _sec(["Preamble"], 1, markdown=_words(60)), + _sec(["IMMEDIATE PRE-OPERATIVE PROTOCOLS"], 7, 9, markdown=_words(60)), + _sec(["Pre transplant day"], 9, markdown=""), + _sec(["PRE-TRANSPLANT RECIPIENT ORDER SHEET"], 9, 10, markdown=_words(60)), + _sec(["6.3.3.2 Donor evaluation"], 11), + ] + pages, _ = self._plan(_llm_reply({"pages": [1, 2]}), sections) + self.assertEqual( + _titles(pages), ["Preamble", "IMMEDIATE PRE-OPERATIVE PROTOCOLS", "6.3.3.2 Donor evaluation"] + ) + self.assertIn("## Pre transplant day\n\n## PRE-TRANSPLANT RECIPIENT ORDER SHEET", pages[1].markdown) + self.assertEqual(pages[1].page_end, 10) + self.assertEqual( + _titles(plan_pages(sections)), + [ + "Preamble", + "IMMEDIATE PRE-OPERATIVE PROTOCOLS", + "PRE-TRANSPLANT RECIPIENT ORDER SHEET", + "6.3.3.2 Donor evaluation", + ], + ) + + def test_a_page_spanning_too_many_pdf_pages_splits_at_its_numbered_children(self): + capd = "6.2.2 CAPD" + initiation = "6.2.2.1 Initiation" + sections = [ + _sec([capd], 4, markdown=""), + _sec([capd, "6.2.2.0 Introduction"], 4, markdown=_words(80)), + _sec([capd, initiation], 4, markdown=""), + _sec([capd, initiation, "6.2.2.1.1 Counselling"], 4, 5, markdown=_words(200)), + _sec([capd, initiation, "6.2.2.1.2 Pre-op evaluation"], 5, 17, markdown=_words(2000)), + _sec([capd, initiation, "6.2.2.1.3 Intra-op"], 17, 20, markdown=_words(800)), + _sec([capd, "6.2.2.2 Note"], 20, markdown=_words(10)), + ] + pages, _ = self._plan(_llm_reply({"pages": []}), sections) + self.assertEqual( + _titles(pages), + [ + capd, + "6.2.2.0 Introduction", + initiation, + "6.2.2.1.1 Counselling", + "6.2.2.1.2 Pre-op evaluation", + "6.2.2.1.3 Intra-op", + ], + ) + self.assertIn("6.2.2.2 Note", pages[-1].markdown) + self.assertNotIn("6.2.2.2 Note", pages[0].markdown) + + def test_a_long_page_without_numbered_children_splits_at_its_sub_headings(self): + access = "6.2.1.4 PROTOCOL FOR VASCULAR ACCESS" + sections = [ + _sec([access], 3, markdown=""), + _sec([access, "AV FISTULA/AV GRAFT"], 3, markdown=_words(60)), + _sec([access, "A. Skin Preparation"], 3, 4, markdown=_words(230)), + _sec([access, "CENTRAL VENOUS CATHETER"], 5, markdown=""), + _sec([access, "CATHETER CARE"], 5, markdown=_words(260)), + _sec([access, "I. EXIT SITE CARE"], 6, 7, markdown=_words(140)), + _sec([access, "DIALYZER REUSE"], 7, markdown=_words(30)), + _sec([access, "IDENTIFYING AND MANAGING COMPLICATIONS"], 7, markdown=""), + _sec([access, "1. INTRA DIALYTIC HYPOTENSION"], 7, 8, markdown=_words(110)), + _sec([access, "2. HYPERKALEMIA"], 9, 10, markdown=_words(80)), + _sec([access, "Peritoneal Dialysis"], 11, markdown=""), + _sec([access, "1 Dialysis Prescription"], 11, markdown=_words(30)), + _sec([access, "2 Peritonitis"], 11, markdown=_words(90)), + _sec([access, "4 Exit Site Infection"], 11, markdown=_words(40)), + ] + pages, _ = self._plan(_llm_reply({"pages": []}), sections) + self.assertEqual( + _titles(pages), + [ + access, + "AV FISTULA/AV GRAFT", + "CENTRAL VENOUS CATHETER", + "Peritoneal Dialysis", + ], + ) + self.assertEqual(pages[0].markdown, "") + self.assertIn("## CATHETER CARE", pages[2].markdown) + self.assertIn( + f"## DIALYZER REUSE\n\n{_words(30)}\n\n## IDENTIFYING AND MANAGING COMPLICATIONS", + pages[2].markdown, + ) + self.assertEqual(pages[3].hierarchy_path, [access, "Peritoneal Dialysis"]) + self.assertEqual((pages[3].page_start, pages[3].page_end), (11, 11)) + + def test_a_short_leading_preamble_joins_the_page_after_it(self): + sections = [ + _sec(["Preamble"], 1, markdown="Saline dialysis is reserved for bleeding patients."), + _sec(["6.2.1 POLICIES"], 2, markdown=""), + _sec(["6.2.1 POLICIES", "6.2.1.3 ASSESSMENT"], 2), + _sec(["6.2.1 POLICIES", "6.2.1.4 VASCULAR ACCESS"], 3), + ] + pages, _ = self._plan(_llm_reply({"pages": [0, 1, 2, 3]}), sections) + self.assertEqual(_titles(pages), ["6.2.1 POLICIES", "6.2.1.3 ASSESSMENT", "6.2.1.4 VASCULAR ACCESS"]) + self.assertEqual(pages[0].markdown, "") + self.assertEqual( + pages[1].markdown, + "Saline dialysis is reserved for bleeding patients.\n\nbody of 6.2.1.3 ASSESSMENT", + ) + + def test_numbered_siblings_follow_the_majority_into_pages(self): + sections = [ + _sec(["5.8 Policies on patient care"], 1, markdown=_words(20)), + _sec(["5.8 Policies on patient care", "5.8.1 Chaperone"], 2, markdown=_words(90)), + _sec(["5.8 Policies on patient care", "5.8.2 Antibiotic policy"], 3, markdown=_words(29)), + _sec(["5.8 Policies on patient care", "5.8.3 Consent"], 4, markdown=_words(200)), + _sec(["5.8 Policies on patient care", "5.8.4 Sedation"], 5, markdown=_words(300)), + _sec(["5.8 Policies on patient care", "5.8.4 Sedation", "Contraindications"], 5), + ] + pages, _ = self._plan(_llm_reply({"pages": [1, 3, 4]}), sections) + self.assertEqual( + _titles(pages), + [ + "5.8 Policies on patient care", + "5.8.1 Chaperone", + "5.8.2 Antibiotic policy", + "5.8.3 Consent", + "5.8.4 Sedation", + ], + ) + pages, _ = self._plan(_llm_reply({"pages": [4]}), sections) + self.assertEqual(_titles(pages), ["5.8 Policies on patient care", "5.8.4 Sedation"]) + + def test_numbered_siblings_after_a_page_are_pages_even_when_short(self): + sections = [ + _sec(["2. Organogram"], 1, markdown=_words(20)), + _sec(["2. Organogram", "2.1 The staff"], 1, markdown=_words(200)), + _sec(["2. Organogram", "2.2 Hierarchy"], 2, markdown=_words(200)), + _sec(["2. Organogram", "2.3 Staff list"], 3, markdown=_words(200)), + _sec(["2. Organogram", "2.4 Laboratory staff"], 4, markdown=_words(60)), + _sec(["2. Organogram", "2.5 Office staff"], 5, markdown=_words(60)), + _sec(["2. Organogram", "2.6 Support staff"], 6, markdown=_words(25)), + _sec(["2. Organogram", "2.7 Attendants"], 6, markdown=_words(5)), + ] + pages, _ = self._plan(_llm_reply({"pages": [0, 2]}), sections) + self.assertEqual( + _titles(pages), + [ + "2. Organogram", + "2.2 Hierarchy", + "2.3 Staff list", + "2.4 Laboratory staff", + "2.5 Office staff", + "2.6 Support staff", + ], + ) + self.assertNotIn("2.6 Support staff", pages[0].markdown + pages[-2].markdown) + self.assertIn("## 2.7 Attendants", pages[-1].markdown) + + def test_a_long_page_never_splits_at_a_form_letterhead_or_a_step(self): + training = "6.2.2.1.5 PD TRAINING PROCEDURE" + sections = [ + _sec([training], 227, markdown=""), + _sec([training, "CHRISTIAN MEDICAL COLLEGE"], 227, markdown=""), + _sec([training, "DEPARTMENT OF NEPHROLOGY"], 227, markdown=""), + _sec([training, "PD TRAINING OBJECTIVES"], 227, 228, markdown=_words(200)), + _sec([training, "DAY WISE SCHEDULE OF TRAINING"], 229, 231, markdown=_words(300)), + _sec([training, "CHRISTIAN MEDICAL COLLEGE VELLORE"], 232, markdown=""), + _sec([training, "PROCEDURE MANUAL - NEPHROLOGY"], 232, markdown=""), + _sec([training, "PD EXCHANGE PROCEDURE"], 232, 233, markdown=_words(229)), + _sec([training, "Step 11: Check, measure, Record and Discard"], 233, 234, markdown=_words(191)), + _sec([training, "DISCHARGE CHECKLIST"], 239, 240, markdown=_words(193)), + _sec([training, "CONTACT INFORMATION SHEET"], 240, markdown=""), + _sec([training, "CHRISTIAN MEDICAL COLLEGE"], 240, markdown=""), + _sec([training, "DEPARTMENT OF NEPHROLOGY"], 240, markdown=_words(178)), + ] + pages, _ = self._plan(_llm_reply({"pages": []}), sections) + self.assertEqual( + _titles(pages), + [ + training, + "PD TRAINING OBJECTIVES", + "DAY WISE SCHEDULE OF TRAINING", + "PD EXCHANGE PROCEDURE", + "DISCHARGE CHECKLIST", + ], + ) + self.assertIn("## Step 11: Check, measure, Record and Discard", pages[3].markdown) + self.assertIn("## CONTACT INFORMATION SHEET", pages[4].markdown) + + def test_sections_sharing_a_pdf_page_share_a_wiki_page(self): + sections = [ + _sec(["2. Organogram"], 25, markdown=_words(12)), + _sec(["2. Organogram", "2.1 The staff"], 25, markdown=_words(50)), + _sec(["2. Organogram", "2.2 Staff hierarchy"], 26, 28, markdown=_words(390)), + _sec(["2. Organogram", "2.3 Staff list"], 29, 32, markdown=_words(1000)), + _sec(["2. Organogram", "2.4 Laboratory staff"], 33, markdown=_words(5)), + _sec(["2. Organogram", "2.5 Research staff"], 33, markdown=_words(5)), + _sec(["2. Organogram", "2.6 Office staff"], 33, markdown=_words(25)), + _sec(["2. Organogram", "2.7 Support staff"], 33, markdown=_words(30)), + ] + pages, _ = self._plan(_llm_reply({"pages": [0, 1, 2, 3, 4, 5, 6, 7]}), sections) + self.assertEqual( + _titles(pages), + ["2. Organogram", "2.2 Staff hierarchy", "2.3 Staff list", "2.4 Laboratory staff"], + ) + self.assertIn("## 2.1 The staff", pages[0].markdown) + for title in ("2.5 Research staff", "2.6 Office staff", "2.7 Support staff"): + self.assertIn(f"## {title}", pages[-1].markdown) + + def test_a_long_numbered_section_keeps_its_page_when_it_starts_mid_page(self): + sections = [ + _sec(["3. Job descriptions"], 34, markdown=_words(80)), + _sec(["3. Job descriptions", "3.1 Head of the department"], 34, 36, markdown=_words(600)), + _sec(["3. Job descriptions", "3.2 Head of the unit"], 37, 38, markdown=_words(300)), + _sec(["3. Job descriptions", "3.3 Consultant"], 38, 39, markdown=_words(180)), + _sec(["3. Job descriptions", "3.4 Registrar"], 39, markdown=_words(90)), + ] + pages, _ = self._plan(_llm_reply({"pages": [0, 1, 2, 3, 4]}), sections) + self.assertEqual( + _titles(pages), + ["3. Job descriptions", "3.1 Head of the department", "3.2 Head of the unit", "3.3 Consultant"], + ) + self.assertIn("## 3.4 Registrar", pages[-1].markdown) diff --git a/wikify/tests/test_sectionize.py b/wikify/tests/test_sectionize.py index b9a248d..e147b57 100644 --- a/wikify/tests/test_sectionize.py +++ b/wikify/tests/test_sectionize.py @@ -132,6 +132,90 @@ def test_content_before_any_heading_becomes_preamble(self): self.assertEqual(secs[0].title, "Preamble") self.assertEqual(secs[0].level, 1) + def test_content_before_the_first_heading_is_kept(self): + pages = [ + ( + 1, + "- ✓ Concessions of Rs 2,500 are discussed.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n## 5.7 Billing\nbill body", + ), + ] + secs = sectionize(pages) + self.assertEqual((secs[0].title, secs[0].hierarchy_path), ("Preamble", ["Preamble"])) + self.assertIn("Concessions of Rs 2,500", secs[0].markdown) + self.assertIn("| 1 | 2 |", secs[0].markdown) + self.assertEqual(secs[1].hierarchy_path, ["5.7 Billing"]) + + def test_running_sub_header_variants_fold_into_the_open_section(self): + running = "## 5.8 Policies on patient care" + pages = [ + (1, f"{running}\n### 5.8.1 Chaperone\na\n{running}\n### 5.8.2 Antibiotics\nb"), + (2, f"{running}\n### 5.8.3 Pain\nc"), + (3, f"{running}\n### 5.8.4 Sedation\nd\n## 5.8Policies on patient care\n### 5.8.5 Restraints\ne"), + ] + secs = sectionize(clean_pages(pages)) + self.assertEqual([s.title for s in secs].count("5.8 Policies on patient care"), 1) + self.assertEqual(secs[0].title, "5.8 Policies on patient care") + self.assertTrue(all(s.hierarchy_path[0] == "5.8 Policies on patient care" for s in secs)) + + def test_repeated_chapter_title_with_another_number_or_plural_is_not_a_new_section(self): + protocols = [ + (1, "# 6.1 PROTOCOLS FOR MANAGEMENT\n## 6.1.1 IgA nephropathy\na"), + (2, "# 6. PROTOCOLS FOR MANAGEMENT\n## 6.1.2 Lupus nephritis\n#### Hydroxychloroquine.\nb"), + ] + paths = {s.title: s.hierarchy_path for s in sectionize(protocols)} + self.assertNotIn("6. PROTOCOLS FOR MANAGEMENT", paths) + self.assertEqual( + paths["6.1.2 Lupus nephritis"], ["6.1 PROTOCOLS FOR MANAGEMENT", "6.1.2 Lupus nephritis"] + ) + self.assertIn("Hydroxychloroquine", paths) + + jobs = [ + (1, "# 3. JOB DESCRIPTIONS\n## 3.1 Head\na"), + (2, "## 3. JOB DESCRIPTION\n## 3.2 Unit head\nb"), + ] + paths = {s.title: s.hierarchy_path for s in sectionize(jobs)} + self.assertNotIn("3. JOB DESCRIPTION", paths) + self.assertEqual(paths["3.2 Unit head"], ["3. JOB DESCRIPTIONS", "3.2 Unit head"]) + + def test_bold_line_continuing_the_numbering_is_a_heading(self): + markdown = ( + "## 5.8.6 Blood Donation\na\n### 5.8.6.2 Donor criteria\nb\n" + "**5.8.6.3 Requesting blood from blood bank**\n**For use in ward**\nc\n" + "**5.8.6.4 For use in OR**\nd\n**1. Not a heading**\ne" + ) + pages = [(1, markdown)] + secs = sectionize(pages) + self.assertEqual( + [(s.title, s.level) for s in secs], + [ + ("5.8.6 Blood Donation", 3), + ("5.8.6.2 Donor criteria", 4), + ("5.8.6.3 Requesting blood from blood bank", 4), + ("5.8.6.4 For use in OR", 4), + ], + ) + self.assertNotIn("Requesting blood", secs[1].markdown) + self.assertIn("**For use in ward**", secs[2].markdown) + self.assertIn("**1. Not a heading**", secs[3].markdown) + + def test_unnumbered_heading_never_parents_a_numbered_heading(self): + pages = [(1, "# Contraindications\na\n# 5.8.4.2 Patient Assessment\nb\n### 5.8.5 Restraints\nc")] + paths = {s.title: s.hierarchy_path for s in sectionize(pages)} + self.assertEqual(paths["5.8.4.2 Patient Assessment"], ["5.8.4.2 Patient Assessment"]) + self.assertEqual(paths["5.8.5 Restraints"], ["5.8.5 Restraints"]) + + def test_numbered_list_headings_nest_under_a_deep_numbered_section(self): + pages = [ + (1, "#### 5.9.2.2 Staff tested for HIV\na\n# 1. Drug Regimen\nb\n# 2. Testing for HBsAg\nc"), + (2, "# 6.1 PROTOCOLS\n## 6.1.1 IgA nephropathy\nd"), + ] + paths = {s.title: s.hierarchy_path for s in sectionize(pages)} + self.assertEqual(paths["1. Drug Regimen"], ["5.9.2.2 Staff tested for HIV", "1. Drug Regimen"]) + self.assertEqual( + paths["2. Testing for HBsAg"], ["5.9.2.2 Staff tested for HIV", "2. Testing for HBsAg"] + ) + self.assertEqual(paths["6.1.1 IgA nephropathy"], ["6.1 PROTOCOLS", "6.1.1 IgA nephropathy"]) + def test_emphasis_is_stripped_from_heading_titles(self): pages = [ (1, "## _**Verbal Orders**_\nbody"), @@ -150,6 +234,13 @@ def test_clean_pages_strips_varying_boilerplate_before_sectionizing(self): self.assertTrue(all("Pg" not in s.markdown for s in secs)) self.assertEqual([s.title for s in secs], ["1. Intro", "2. Next"]) + def test_clean_pages_strips_a_header_style_that_covers_only_part_of_a_long_document(self): + pages = [ + (number, f"{'NEPHROLOGY MANUAL' if number % 4 == 0 else 'PROCEDURE MANUAL'}\n\nbody {number}") + for number in range(1, 101) + ] + self.assertTrue(all("MANUAL" not in markdown for _, markdown in clean_pages(pages))) + def test_clean_pages_strips_signoff_footer_block(self): footer = "|**Prepared by - Dr. A**|**Issued by: QMC**|**Approved by - Dr. B**|\n|---|---|---|" pages = [(1, f"## 1. Intro\nreal body\n{footer}"), (2, f"## 2. Next\nmore body\n{footer}")] @@ -161,6 +252,38 @@ def test_clean_pages_strips_signoff_footer_block(self): self.assertIn("real body", cleaned[1]) self.assertIn("more body", cleaned[2]) + def test_clean_pages_strips_page_numbers_and_headless_separator_rows(self): + header = "NEPHROLOGY MANUAL\n\n| MAN/RPT/NEPH/001 | Ver.: 1 | Pg. {page} of 398 |\n|---|---|---|" + table = "| Health worker | Action |\n|---|---|\n| Antibody 100 | Reassure |" + pages = [ + (1, header.format(page=121) + f"\n\n## 5.7 Billing\n\n- Pay 2500 by noon.\n\n{table}\n\n121"), + (2, header.format(page=122) + "\n\n- Second page item.\n\n122"), + (3, header.format(page=123) + "\n\n- Third page item.\n\n123"), + ] + cleaned = dict(clean_pages(pages)) + self.assertFalse(cleaned[1].startswith("|")) + self.assertEqual(cleaned[2].strip(), "- Second page item.") + self.assertNotIn("121", cleaned[1]) + self.assertIn(f"- Pay 2500 by noon.\n\n{table}", cleaned[1]) + + def test_clean_pages_rejoins_a_sentence_split_by_a_page_break(self): + pages = [ + (1, "- Organs are retrieved after withdrawal of life-sustaining\n\n132"), + ( + 2, + "measures; the kidneys may be recovered.\n\n## 5.8.12 Grievances\n\n- Last item ends.\n\n133", + ), + (3, "- New item starts the page.\n\n134"), + ] + cleaned = dict(clean_pages(pages)) + self.assertEqual( + cleaned[1], + "- Organs are retrieved after withdrawal of life-sustaining measures; the kidneys may be recovered.", + ) + self.assertNotIn("measures", cleaned[2]) + self.assertIn("## 5.8.12 Grievances", cleaned[2]) + self.assertEqual(cleaned[3], "- New item starts the page.") + def test_title_is_clipped_to_the_storable_length(self): long_title = "DETERMINATION OF RESIDENTIAL STATUS OF " + "HINDU UNDIVIDED FAMILY " * 8 secs = sectionize([(1, f"## {long_title}\nbody")]) @@ -214,6 +337,543 @@ def test_clean_pages_keeps_data_row_mentioning_approved_by_once(self): self.assertIn("approved by committee", cleaned[1]) self.assertIn("|---|---|", cleaned[1]) + def test_table_of_contents_is_one_section_that_never_opens_chapters(self): + table_rows = "\n".join(f"| 1.{n} | Scope of service {n} | {18 + n} |" for n in range(1, 7)) + procedure_rows = "\n".join(f"| 5.{n} | Policies for topic {n} | {95 + n} |" for n in range(1, 7)) + list_rows = "\n".join(f" - 6.1.{n} Protocol {n} — {200 + n}" for n in range(1, 7)) + pages = [ + (1, "# PROCEDURE MANUAL\n\n## REVISION HISTORY\nrevisions"), + (2, f"## Contents\n\n| S.No | CONTENTS | Pg No |\n|---|---|---|\n{table_rows}"), + ( + 3, + f"## 5 Procedures for access of the patients\n\n| No. | Topic | Page |\n|---|---|---|\n{procedure_rows}", + ), + (4, f"- 6.1 Protocols — 199\n{list_rows}\n\n**4**"), + (5, "# 1. PROFILE OF THE DEPARTMENT\n\n## Overview\nIn the year 1900 the institution began."), + ] + secs = sectionize(pages) + self.assertEqual( + [(s.title, s.level) for s in secs], + [ + ("PROCEDURE MANUAL", 1), + ("REVISION HISTORY", 2), + ("Contents", 1), + ("1. PROFILE OF THE DEPARTMENT", 1), + ("Overview", 2), + ], + ) + contents = secs[2] + self.assertEqual((contents.page_start, contents.page_end), (2, 4)) + self.assertIn("5 Procedures for access of the patients", contents.markdown) + self.assertIn("6.1.6 Protocol 6 — 206", contents.markdown) + + def test_link_markup_is_stripped_from_heading_titles(self): + secs = sectionize([(1, "# [PROCEDURE MANUAL - NEPHROLOGY](#)\n\n## REVISION HISTORY\nrows")]) + self.assertEqual(secs[0].title, "PROCEDURE MANUAL - NEPHROLOGY") + + def test_lowercase_or_colon_heading_is_a_bold_label(self): + markdown = ( + "# **IMMEDIATE PRE-OPERATIVE PROTOCOLS**\nintro\n# **are carried out as below:**\n" + "- PreHD: WBC\n# **Drug list:**\n- MMF 8 am\n# 5.1 Definitions:\nterms" + ) + pages = [(1, markdown)] + secs = sectionize(pages) + self.assertEqual([s.title for s in secs], ["IMMEDIATE PRE-OPERATIVE PROTOCOLS", "5.1 Definitions:"]) + self.assertIn("**are carried out as below:**\n- PreHD: WBC\n**Drug list:**", secs[0].markdown) + + def test_clean_pages_strips_signoff_footer_written_as_text(self): + pipes = "Prepared by: Dr. A, Dr. B | Issued by: QMC | Approved by: Dr. C" + merged = "**Prepared by: Dr. A, Dr. Issued by: QMC Approved by: Dr. C B** " + paragraphs = "Prepared by: Dr. A, Dr. B\n\nIssued by: QMC\n\nApproved by: Dr. C" + pages = [ + (1, f"## DONOR WITH OBESITY\nreal body\n\n{pipes}"), + (2, f"- more body\n\n{merged}\n"), + (3, f"## DIABETIC PATIENT\n\n{paragraphs}\n\nThe protocol is approved by the HOD."), + ] + cleaned = dict(clean_pages(pages)) + for md in cleaned.values(): + self.assertNotIn("Prepared by", md) + self.assertNotIn("Issued by", md) + self.assertIn("real body", cleaned[1]) + self.assertIn("- more body", cleaned[2]) + self.assertIn("approved by the HOD", cleaned[3]) + + def test_clean_pages_keeps_html_tables_that_recur_on_every_page(self): + header = "PROCEDURE MANUAL - NEPHROLOGY\n\nMAN/RPT/NEPH/001 | Ver.: 1 | Pg. {page} of 398\n\n" + table = ( + "\n\n\n\n\n" + "\n\n\n\n" + "\n
Nursing ActionRationale
1. Check doctor's order.Gains cooperation
2. Administer inj. calcium gluconate.Prevents hypocalcemia\n" + "
3. Remove sutures.Avoids blood loss
" + ) + pages = [ + (page, header.format(page=page) + f"## Step {page}\n\n{table}\n\nNotes {page}.") + for page in (1, 2, 3) + ] + cleaned = dict(clean_pages(pages)) + self.assertIn( + "| Nursing Action | Rationale |\n|---|---|\n" + "| 1. Check doctor's order. | Gains cooperation |\n" + "| 2. Administer inj. calcium gluconate. | Prevents hypocalcemia |\n" + "| 3. Remove sutures. | Avoids blood loss |", + cleaned[2], + ) + + def test_clean_pages_stitches_a_table_row_split_by_a_page_break(self): + header = "PROCEDURE MANUAL - NEPHROLOGY\n\nMAN/RPT/NEPH/001 | Ver.: 1 | Pg. {page} of 398\n\n" + first = ( + "|**Nursing Action**|**Rationale**|\n|---|---|\n" + "|1. Inspect the previous dressing|Confirms the need.|\n" + "|2. Explain the procedure and
position him according to the|Helps gain co-operation.|\n" + "|**MAN/RPT/NEPH/001 Ver.:**|**1 Issue :1**
Pg.**2 of 398**|\n" + "|convenience to access the site.||\n" + "|3. Send blood for investigations|Electrolytes are removed during the process of|" + ) + second = ( + "\n\n\n\n\n" + "\n\n\n\n
(Ca, PO4, Na, K)plasmapheresis.
4. Check BP.Identifies complications.
\n\n" + "# **Specific Instructions:**\n\n- Avoid swimming." + ) + pages = [ + (1, header.format(page=1) + first), + (2, header.format(page=2) + second), + (3, header.format(page=3)), + ] + secs = sectionize(clean_pages(pages)) + self.assertEqual( + secs[0].markdown, + "|**Nursing Action**|**Rationale**|\n|---|---|\n" + "|1. Inspect the previous dressing|Confirms the need.|\n" + "| 2. Explain the procedure and
position him according to the convenience to access the site. " + "| Helps gain co-operation. |\n" + "| 3. Send blood for investigations (Ca, PO4, Na, K) " + "| Electrolytes are removed during the process of plasmapheresis. |\n" + "| 4. Check BP. | Identifies complications. |\n\n" + "**Specific Instructions:**\n\n- Avoid swimming.", + ) + + def test_clean_pages_strips_running_headers_wrapped_in_markup(self): + pages = [ + ( + 1, + "PROCEDURE MANUAL\n\n> **MAN/RPT/NEPH/001 Ver.: 1**\n\n> **Date:10/11/2023** Pg. **321 of 398**\n\n- First.", + ), + ( + 2, + "||PROCEDURE MANUAL||\n|---|---|---|\n|**MAN/RPT/NEPH/001**|
**Ver.: 1**|Pg.**322 of 398**|\n\n- Second.", + ), + ( + 3, + "PROCEDURE MANUAL\n\n**MAN/RPT/NEPH/001 Ver.: 1 Date:10/11/2023** Pg. **323 of 398**\n\n- Third.", + ), + ( + 4, + "PROCEDURE MANUAL **MAN/RPT/NEPH/001 Ver.: 1 Date:10/11/2023** Pg. **324 of 398**\n\n- Fourth.\n\n**324**", + ), + ] + cleaned = dict(clean_pages(pages)) + self.assertEqual(cleaned, {1: "- First.", 2: "- Second.", 3: "- Third.", 4: "- Fourth."}) + + def test_clean_pages_keeps_a_label_that_recurs_mid_page(self): + header = "PROCEDURE MANUAL\n\nMAN/RPT/NEPH/001 | Ver.: 1 | Pg. {page} of 398\n\n" + body = "## 6.4.{page} Step {page}\n\n**Definition**\n\nText {page}.\n\n## Procedure\n\n1. Do {page}.\n\nFooter note" + pages = [(page, header.format(page=page) + body.format(page=page)) for page in range(1, 6)] + cleaned = dict(clean_pages(pages)) + for page in range(1, 6): + self.assertEqual( + cleaned[page], + f"## 6.4.{page} Step {page}\n\n**Definition**\n\nText {page}.\n\n## Procedure\n\n1. Do {page}.", + ) + + def test_stray_bold_markers_are_stripped_from_titles(self): + secs = sectionize([(1, "# 3. **Other equipment:\n- Gloves\n# **CRRT Order sheet** :\n- Form")]) + self.assertEqual([s.title for s in secs], ["3. Other equipment:"]) + self.assertIn("**CRRT Order sheet :**", secs[0].markdown) + + def test_numbered_list_items_inside_a_sub_section_stay_under_it(self): + extra_duties = ( + "# 4. Extra procedures to be performed by the CAPD nurse educator:\n\n- Assist PET\n\n" + "# 5. Other responsibilities of the CAPD Nurse educator\n\n- Attend meetings" + ) + administration = ( + "## 11. Administration\n\n- Plan duty rosters\n\n" + "## 3.6.9 RENAL TRANSPLANT CO-ORDINATOR\n\n**Qualification:** nurse" + ) + pages = [ + (5, "## 3.6.8 CAPD Nurse Educator\n\nThe CAPD nurse educator trains patients."), + ( + 7, + "# 3. Responsibilities of the CAPD Nurse Educator towards inpatients\n\n- Check the log-book", + ), + (8, extra_duties), + (9, administration), + (13, "## 3.7 Allied Health Staff\n\n### 3.7.1 Pharmacist\n\n- Dispensing"), + (14, "# 4. PROTOCOLS\n\n## 4.1 Dialysis\n\n- Prime the circuit"), + ] + self.assertEqual( + [section.hierarchy_path for section in sectionize(pages)], + [ + ["3.6.8 CAPD Nurse Educator"], + [ + "3.6.8 CAPD Nurse Educator", + "3. Responsibilities of the CAPD Nurse Educator towards inpatients", + ], + [ + "3.6.8 CAPD Nurse Educator", + "4. Extra procedures to be performed by the CAPD nurse educator:", + ], + ["3.6.8 CAPD Nurse Educator", "5. Other responsibilities of the CAPD Nurse educator"], + ["3.6.8 CAPD Nurse Educator", "11. Administration"], + ["3.6.9 RENAL TRANSPLANT CO-ORDINATOR"], + ["3.7 Allied Health Staff"], + ["3.7 Allied Health Staff", "3.7.1 Pharmacist"], + ["4. PROTOCOLS"], + ["4. PROTOCOLS", "4.1 Dialysis"], + ], + ) + + def test_a_parent_heading_met_after_its_sub_sections_becomes_their_group(self): + policies = "6.2.1 POLICIES FOR MANAGEMENT OF HEMODIALYSIS" + first_page = ( + "Saline dialysis is reserved for bleeding patients.\n\n" + "## 6.2.1.3ASSESSMENT AND MONITORING\n\nPatients develop complications." + ) + pages = [ + (1, first_page), + (3, "## 6.2.1.4 PROTOCOL FOR VASCULAR ACCESS\n\n### AV FISTULA\n\nCannulate with care."), + (19, f"# {policies}\n\n## 6.2.1.8 Emergency Dialysis\n\nDialyse within an hour."), + (20, f"## {policies}\n\n### 6.2.1.9 Temporary Vascular Access\n\nUse a femoral catheter."), + ] + sections = sectionize(pages) + self.assertEqual( + [section.hierarchy_path for section in sections], + [ + ["Preamble"], + [policies], + [policies, "6.2.1.3 ASSESSMENT AND MONITORING"], + [policies, "6.2.1.4 PROTOCOL FOR VASCULAR ACCESS"], + [policies, "6.2.1.4 PROTOCOL FOR VASCULAR ACCESS", "AV FISTULA"], + [policies, "6.2.1.8 Emergency Dialysis"], + [policies, "6.2.1.9 Temporary Vascular Access"], + ], + ) + self.assertEqual((sections[1].level, sections[1].markdown, sections[1].page_start), (3, "", 1)) + self.assertEqual(sections[4].markdown, "Cannulate with care.") + + def test_running_chapter_header_at_a_page_top_is_dropped(self): + pages = [ + (10, "## 3.6.9 RENAL TRANSPLANT CO-ORDINATOR\n\n- Maintain the registry."), + ( + 13, + "# 3. JOB DESCRIPTIONS\n\n## 3.7 Allied Health Staff\n\n### 3.7.1 Pharmacist\n\n- Dispensing", + ), + (14, "### 3.7.2 Transplant Coordinator\n\n- Counsel donors."), + (15, "# 3. JOB DESCRIPTION\n\n## 3.7.3 Dialysis therapists\n\n- Prime the circuit."), + ] + sections = sectionize(pages) + self.assertEqual( + [section.title for section in sections], + [ + "3.6.9 RENAL TRANSPLANT CO-ORDINATOR", + "3.7 Allied Health Staff", + "3.7.1 Pharmacist", + "3.7.2 Transplant Coordinator", + "3.7.3 Dialysis therapists", + ], + ) + self.assertFalse(any("JOB DESCRIPTION" in section.markdown for section in sections)) + + def test_a_numbered_section_never_nests_under_another_chapter(self): + markdown = ( + "# 3. **Other equipment:** \n\n- Gloves\n\n## Patient Preparation\n\nExplain the procedure.\n\n" + "# **6.4.2. CENTRAL VENOUS CATHETERIZATION** \n\n# **Definition** \n\nA double lumen catheter." + ) + pages = [(1, markdown)] + self.assertEqual( + [section.hierarchy_path for section in sectionize(pages)], + [ + ["3. Other equipment:"], + ["3. Other equipment:", "Patient Preparation"], + ["6.4.2. CENTRAL VENOUS CATHETERIZATION"], + ["6.4.2. CENTRAL VENOUS CATHETERIZATION", "Definition"], + ], + ) + + def test_clean_pages_keeps_a_list_continued_on_the_next_page_in_its_place(self): + hyperkalemia = ( + "## 7. HYPERKALEMIA\n\n4. **Drugs:** given on verbal order\n" + " - Administer Inj. Dextrose 50% with Inj. Actrapid.\n" + " - Administer Salbutamol nebulization 5mg.\n\n169" + ) + pages = [ + (169, hyperkalemia), + ( + 170, + "- Keep Inj.Calcium Gluconate 10% ready.\n\n## CENTRAL VENOUS CATHETER BLOCK\n\n- Flush it.\n\n170", + ), + ( + 323, + "# **2. Patient/family**\n\n- 1) Explain the cost to the patient\n\n- 2) Obtain consent\n\n323", + ), + ( + 324, + "3) Instruct patient not to touch the sterile area\n4) Explain it is a temporary access\n\n## Procedure", + ), + ] + cleaned = dict(clean_pages(pages)) + self.assertEqual( + cleaned[170], + " - Keep Inj.Calcium Gluconate 10% ready.\n\n## CENTRAL VENOUS CATHETER BLOCK\n\n- Flush it.", + ) + self.assertEqual( + cleaned[324], + "- 3) Instruct patient not to touch the sterile area\n- 4) Explain it is a temporary access\n\n## Procedure", + ) + + def test_a_chapter_the_body_never_opens_becomes_the_group_of_its_sections(self): + chapter = "6. PROTOCOLS FOR CLINICAL NEPHROLOGY" + contents = "\n".join( + [ + "| S.No | CONTENTS | Pg No |", + "|---|---|---|", + "| 5 | Procedures for access | 96 |", + "| 5.9 | Policies on reporting | 136 |", + "| 6.1 | Protocols for clinical nephrology | 140 |", + "| 6.2 | Procedures in dialysis | 158 |", + "| 6.3 | Transplantation | 267 |", + "| 6.3.3 | Policies for pre-transplant evaluation | 270 |", + ] + ) + transplantation = ( + f"## {chapter}\n\n### 6.3.0 TRANSPLANTATION\n\nKidney transplants.\n\n" + "## 6.3.2 Organ donation\n\nBrain death.\n\n## POLICIES FOR PRE-TRANSPLANT EVALUATION\n\n" + "### 6.3.3.1 RECIPIENT WORKUP\n\nPreliminary evaluation." + ) + pages = [ + (2, contents), + (91, "# 5. PROCEDURES FOR ACCESS\n\n## 5.9 Policies on reporting\n\nReport incidents."), + (137, "# 6.1 PROTOCOLS FOR CLINICAL NEPHROLOGY\n\n## 6.1.1 IgA nephropathy\n\nGive steroids."), + (138, f"# {chapter}\n\n## 6.1.2 Lupus nephritis\n\nBiopsy first."), + (155, f"## {chapter}\n\n### 6.2 PROCEDURES IN DIALYSIS\n\nThe dialysis unit."), + (275, transplantation), + ] + protocols = [chapter, "6.1 PROTOCOLS FOR CLINICAL NEPHROLOGY"] + transplant = [chapter, "6.3 Transplantation"] + evaluation = [*transplant, "6.3.3 Policies for pre-transplant evaluation"] + self.assertEqual( + [section.hierarchy_path for section in sectionize(pages)[1:]], + [ + ["5. PROCEDURES FOR ACCESS"], + ["5. PROCEDURES FOR ACCESS", "5.9 Policies on reporting"], + [chapter], + protocols, + [*protocols, "6.1.1 IgA nephropathy"], + [*protocols, "6.1.2 Lupus nephritis"], + [chapter, "6.2 PROCEDURES IN DIALYSIS"], + transplant, + [*transplant, "6.3.0 TRANSPLANTATION"], + [*transplant, "6.3.2 Organ donation"], + evaluation, + [*evaluation, "6.3.3.1 RECIPIENT WORKUP"], + ], + ) + + def test_a_list_heading_numbered_like_the_next_chapter_stays_in_its_section(self): + complications = ( + "## 6.2.1.4 PROTOCOL FOR VASCULAR ACCESS\n\n# IDENTIFYING AND MANAGING COMPLICATIONS\n\n" + "## 4. AIR EMBOLISM\n\nClamp the line.\n\n# 7. HYPERKALEMIA\n\nGive calcium.\n\n" + "## CENTRAL VENOUS CATHETER BLOCK\n\nUse urokinase." + ) + pages = [ + (91, "# 5. PROCEDURES FOR ACCESS\n\n## 5.1 Policies for access\n\nRegister patients."), + (137, "# 6. PROTOCOLS\n\n## 6.2 PROCEDURES IN DIALYSIS\n\n### 6.2.1 HEMODIALYSIS\n\nThe unit."), + (167, complications), + (172, "## 6.2.1.5 HEPATITIS B VACCINATION\n\nVaccinate."), + (179, "## 6.2.1POLICIES FOR HEMODIALYSIS\n\n### 6.2.1.8 Emergency Dialysis\n\nDialyse now."), + (180, "## 6.2.1 POLICIES FOR HEMODIALYSIS\n\n### 6.2.1.9 Temporary Access\n\nFemoral line."), + (371, "# 7. PATIENT RIGHTS AND EDUCATION\n\n## 7.1 Privacy\n\nScreens are used."), + ] + hemodialysis = ["6. PROTOCOLS", "6.2 PROCEDURES IN DIALYSIS", "6.2.1 HEMODIALYSIS"] + access = [*hemodialysis, "6.2.1.4 PROTOCOL FOR VASCULAR ACCESS"] + paths = {section.title: section.hierarchy_path for section in sectionize(pages)} + self.assertEqual(paths["7. HYPERKALEMIA"], [*access, "7. HYPERKALEMIA"]) + self.assertEqual(paths["CENTRAL VENOUS CATHETER BLOCK"], [*access, "CENTRAL VENOUS CATHETER BLOCK"]) + self.assertEqual( + paths["6.2.1.5 HEPATITIS B VACCINATION"], [*hemodialysis, "6.2.1.5 HEPATITIS B VACCINATION"] + ) + self.assertEqual(paths["6.2.1.9 Temporary Access"], [*hemodialysis, "6.2.1.9 Temporary Access"]) + self.assertNotIn("6.2.1 POLICIES FOR HEMODIALYSIS", paths) + + def test_a_lettered_running_header_of_the_open_section_is_dropped(self): + policies = "6.2.1 POLICIES FOR MANAGEMENT OF HEMODIALYSIS" + introduction = ( + "### 6.2.1HEMODIALYSIS\n\n#### 6.2.1.aIntroduction to the unit\n\nThe unit has 40 machines." + ) + icu = ( + "# ICU dialysis\n\nBedside dialysis.\n\n# 6.2.1.b POLICIES FOR MANAGEMENT OF HEMODIALYSIS\n\n" + "## 6.2.1.1 PROTOCOL FOR INITIATING\n\nCheck consent." + ) + pages = [ + (155, introduction), + (157, icu), + (179, f"## {policies}\n\n### 6.2.1.8 Emergency Dialysis\n\nDialyse within an hour."), + (180, f"## {policies}\n\n### 6.2.1.9 Temporary Vascular Access\n\nUse a femoral line."), + ] + sections = sectionize(pages) + self.assertEqual( + [section.title for section in sections], + [ + "6.2.1 HEMODIALYSIS", + "6.2.1.aIntroduction to the unit", + "ICU dialysis", + "6.2.1.1 PROTOCOL FOR INITIATING", + "6.2.1.8 Emergency Dialysis", + "6.2.1.9 Temporary Vascular Access", + ], + ) + self.assertEqual(sections[2].markdown, "Bedside dialysis.") + + def test_a_bold_list_item_numbered_like_the_next_chapter_does_not_take_the_chapter(self): + pages = [ + (91, "# 5. PROCEDURES FOR ACCESS\n\n## 5.1 Policies for access\n\nRegister patients."), + (137, "# 6. PROTOCOLS\n\n## 6.2.1.4 PROTOCOL FOR VASCULAR ACCESS\n\nCannulate with care."), + (169, "**6. PULMONARY EDEMA**\n\nGive oxygen.\n\n**7. HYPERKALEMIA**\n\nGive calcium."), + (170, "## 6.2.1.5 HEPATITIS B VACCINATION\n\nVaccinate."), + ( + 371, + "# **7. PATIENT RIGHTS AND EDUCATION** \n\nRights are displayed.\n\n# **7.1 Privacy**\n\nScreens.", + ), + ] + sections = sectionize(pages) + self.assertEqual( + [section.hierarchy_path for section in sections if section.title.startswith("7")], + [["7. PATIENT RIGHTS AND EDUCATION"], ["7. PATIENT RIGHTS AND EDUCATION", "7.1 Privacy"]], + ) + self.assertIn("**7. HYPERKALEMIA**", sections[3].markdown) + + def test_a_misprinted_chapter_in_a_deep_number_is_read_as_the_current_chapter(self): + kinship = ( + "## 6.5.3. PROCEDURES FOR TESTING\n\n### 6.5.3.1. KINSHIP TESTING PROTOCOL\n\nDNA test.\n\n" + "#### 6.5.3.1.4. Turn-Around-Time:\n\nTwo weeks.\n\n" + "#### 1.5.3.1.5. PATIENT'S RIGHTS & CONFIDENTIALITY\n\nReports are sealed." + ) + endotoxin = ( + "## 6.5.3.2.0. Endotoxin Testing Protocol\n\nLAL assay.\n\n" + "## 1.5.3.2.1 Pre Analytical Phase :\n\nCollect water.\n\n## 6.5.4 RECORDS MAINTAINED\n\nRegisters." + ) + testing = "6.5.3. PROCEDURES FOR TESTING" + protocol = [testing, "6.5.3.1. KINSHIP TESTING PROTOCOL"] + endotoxin_group = [testing, "6.5.3.2. Endotoxin Testing Protocol"] + self.assertEqual( + [section.hierarchy_path for section in sectionize([(356, kinship), (361, endotoxin)])], + [ + [testing], + protocol, + [*protocol, "6.5.3.1.4. Turn-Around-Time:"], + [*protocol, "6.5.3.1.5. PATIENT'S RIGHTS & CONFIDENTIALITY"], + endotoxin_group, + [*endotoxin_group, "6.5.3.2.0. Endotoxin Testing Protocol"], + [*endotoxin_group, "6.5.3.2.1 Pre Analytical Phase :"], + ["6.5.4 RECORDS MAINTAINED"], + ], + ) + + def test_html_tags_are_stripped_from_heading_titles(self): + markdown = ( + "## 2.3. STAFF LIST as on 24th May 2018\n\nNames.\n\n" + "## **2.4 CODE RED PROTOCOL** \n\nEvacuate." + ) + self.assertEqual( + [section.title for section in sectionize([(29, markdown)])], + ["2.3. STAFF LIST as on 24th May 2018", "2.4 CODE RED PROTOCOL"], + ) + + def test_clean_pages_drops_a_running_header_stitched_inside_a_table(self): + header = "NEPHROLOGY MANUAL\n\nMAN/RPT/NEPH/001 | Ver.: 1 | Issue : 1 | Pg. {page} of 398\n\n" + timeline = ( + "| Year | Milestone |\n|---|---|\n" + "| 2005 | Nephrology Laboratory Started |\n" + "| 2007 | DNA fingerprinting facility established to ascertain genetic relationship between |\n" + "||NEPHROLOGY MANUAL
|\n" + "||**MAN/RPT/NEPH/001**
**Ver.: 1**
**Issue : 1**
**Date:10/11/2023**
Pg.**14 of 398**|\n" + "||transplant patient & prospective donor|\n" + "|2008|First CAPD catheterization done by percutaneous method|" + ) + header_box = ( + '\n\n\n\n\n' + "\n\n\n\n" + "\n
NEPHROLOGY MANUAL
MAN/RPT/NEPH/001Ver.: 1Issue : 1Pg. 15 of 398
\n\n" + ) + pages = [ + (13, header.format(page=13) + timeline), + (14, header.format(page=14) + "# Education\n\nTwo programmes."), + (15, header_box + "Every staff member keeps this manual correct."), + (16, header.format(page=16) + "Closing notes."), + ] + cleaned = dict(clean_pages(pages)) + self.assertEqual( + cleaned[13], + "| Year | Milestone |\n|---|---|\n" + "| 2005 | Nephrology Laboratory Started |\n" + "| 2007 | DNA fingerprinting facility established to ascertain genetic relationship between " + "transplant patient & prospective donor |\n" + "|2008|First CAPD catheterization done by percutaneous method|", + ) + self.assertEqual(cleaned[15], "Every staff member keeps this manual correct.") + + def test_clean_pages_stitches_a_continuation_that_lost_an_empty_column_and_its_header(self): + checklist = ( + "### PRE-OPERATIVE CHECKLIST FOR PD NURSE\n\n\n\n\n\n\n\n" + "\n\n" + "\n\n\n" + "\n\n\n\n\n
Tick (√)
PD education material given in patient's own language\n
    \n" + "
  • Introductory PD pamphlet
  • \n
  • PD patient information sheet
  • \n
\n
Patient's ability to do CAPD ensured
\n\n212" + ) + continuation = ( + "| Care-giver identified | Specify |\n|---|---|\n" + "| Dedicated PD area at home identified | |\n" + "| Date of surgery decided | Date: |\n\n" + "Signature of the PD nurse." + ) + cleaned = dict(clean_pages([(212, checklist), (213, continuation)])) + self.assertEqual( + cleaned[212], + "### PRE-OPERATIVE CHECKLIST FOR PD NURSE\n\n| | Tick (√) | |\n|---|---|---|\n" + "| PD education material given in patient's own language
• Introductory PD pamphlet" + "
• PD patient information sheet | | |\n" + "| Patient's ability to do CAPD ensured | | |\n" + "| Care-giver identified | | Specify |\n" + "| Dedicated PD area at home identified | | |\n" + "| Date of surgery decided | | Date: |", + ) + self.assertEqual(cleaned[213], "Signature of the PD nurse.") + + def test_clean_pages_lays_contents_lines_out_as_the_contents_table_they_continue(self): + contents_table = ( + "# CONTENTS\n\n| S.No | CONTENTS | Pg No |\n|---|---|---|\n" + "| 3.5.4 | Fellow in Interventional Nephrology | 46 |\n" + "| 3.5.5 | Responsibilities of Dialysis Registrar | 47 |" + ) + contents_lines = ( + "3.5.6 Transplant registrar 50\n3.5.7 Fellow in Renal Transplantation 51\n3.6 Nursing staff 59\n" + "3.6.1 Nurse manager 57\n3.9.1 Hospital Attendant -Posted in clinical Area (A.K. Lab) 88\n" + "4 Records maintained 93" + ) + cleaned = dict(clean_pages([(2, contents_table), (3, contents_lines)])) + self.assertEqual( + cleaned[2], + "# CONTENTS\n\n| S.No | CONTENTS | Pg No |\n|---|---|---|\n" + "| 3.5.4 | Fellow in Interventional Nephrology | 46 |\n" + "| 3.5.5 | Responsibilities of Dialysis Registrar | 47 |\n" + "| 3.5.6 | Transplant registrar | 50 |\n" + "| 3.5.7 | Fellow in Renal Transplantation | 51 |\n" + "| 3.6 | Nursing staff | 59 |\n" + "| 3.6.1 | Nurse manager | 57 |\n" + "| 3.9.1 | Hospital Attendant -Posted in clinical Area (A.K. Lab) | 88 |\n" + "| 4 | Records maintained | 93 |", + ) + self.assertEqual(cleaned[3], "") + class TestEmptySectionsAreFlagged(FrappeTestCase): def test_section_without_markdown_is_chunked_as_title_only(self):