实现 ClinDB 第一批清洗组件

This commit is contained in:
2026-08-23 20:56:36 +08:00
parent 6fcc7d5736
commit 80001a8ab9
28 changed files with 2538 additions and 67 deletions
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
import ast
from pathlib import Path
from mdpolish import Pipeline, RunStatus
from mdpolish.components import (
ArxivSubmissionStampComponent,
HtmlTableDoubleEscapeComponent,
HtmlTableLayoutComponent,
ManuscriptLineNumberComponent,
PageBreakWordJoinComponent,
ReferenceSpacingComponent,
RepeatedRunningHeaderComponent,
WordReviewCommentComponent,
)
STAMP = "arXiv:2104.12345v2 [stat.ME] 31 Dec 2021"
HEADER = "## Repeated Paper Header"
MAPPINGS = (
("medi-", "cal", "medical"),
("possi-", "bly", "possibly"),
("cre-", "ated", "created"),
("SOFA-", "based", "SOFA-based"),
("life-", "threatening", "life-threatening"),
("threshold.", "olds", "thresholds"),
)
def _build_pipeline() -> Pipeline:
return Pipeline(
[
WordReviewCommentComponent(),
ManuscriptLineNumberComponent(),
ArxivSubmissionStampComponent(),
RepeatedRunningHeaderComponent(),
PageBreakWordJoinComponent(MAPPINGS),
HtmlTableDoubleEscapeComponent(),
HtmlTableLayoutComponent(),
ReferenceSpacingComponent(),
]
)
def _numbered_manuscript() -> list[str]:
return [
f"## {number} Section {number}" if number in {5, 15} else f"{number} body {number}"
for number in range(1, 21)
]
def _combined_markdown() -> str:
return "\n".join(
(
"1 Affiliation",
"## Abstract",
*_numbered_manuscript(),
"Commented [A1]: remove this",
"",
STAMP,
"Sentence continues in",
"",
HEADER,
"",
"the next line.",
"A word is possi-",
"",
"bly split.",
"<table><tr><td>&amp;lt;5</td></tr><tr><td>B</td></tr></table>",
"## References",
"",
"1. First",
"",
"2. Second",
"",
HEADER,
"",
"3. Third",
"4. Fourth",
)
)
def test_script_builds_frozen_component_order_and_parameters() -> None:
script_path = Path(__file__).parents[1] / "scripts" / "run_clindb_first_batch_experiment.py"
module = ast.parse(script_path.read_text(encoding="utf-8"))
build_function = next(
node for node in module.body if isinstance(node, ast.FunctionDef) and node.name == "build_pipeline"
)
component_names = [
call.func.id
for node in ast.walk(build_function)
if isinstance(node, ast.List)
for call in node.elts
if isinstance(call, ast.Call) and isinstance(call.func, ast.Name)
]
mapping_assignment = next(
node
for node in module.body
if isinstance(node, ast.Assign)
and any(isinstance(target, ast.Name) and target.id == "CLINDB_WORD_JOIN_MAPPINGS" for target in node.targets)
)
assert component_names == [
"WordReviewCommentComponent",
"ManuscriptLineNumberComponent",
"ArxivSubmissionStampComponent",
"RepeatedRunningHeaderComponent",
"PageBreakWordJoinComponent",
"HtmlTableDoubleEscapeComponent",
"HtmlTableLayoutComponent",
"ReferenceSpacingComponent",
]
assert ast.literal_eval(mapping_assignment.value) == MAPPINGS
pipeline = _build_pipeline()
result = pipeline.transform("")
assert [component.component_id for component in result.components] == [
"paper.word_review_comment",
"paper.manuscript_line_number",
"paper.arxiv_submission_stamp",
"paper.repeated_running_header",
"paper.page_break_word_join",
"markdown.html_table_double_escape",
"markdown.html_table_layout",
"paper.reference_spacing",
]
assert len(MAPPINGS) == 6
def test_full_pipeline_is_audited_stable_and_idempotent() -> None:
pipeline = _build_pipeline()
first = pipeline.transform(_combined_markdown())
assert first.status is RunStatus.SUCCESS
assert first.output_markdown is not None
assert first.residual_proposals == ()
counts: dict[str, int] = {}
for change in first.changes:
counts[change.component_id] = counts.get(change.component_id, 0) + 1
assert counts == {
"paper.word_review_comment": 1,
"paper.manuscript_line_number": 20,
"paper.arxiv_submission_stamp": 1,
"paper.repeated_running_header": 2,
"paper.page_break_word_join": 1,
"markdown.html_table_double_escape": 1,
"markdown.html_table_layout": 1,
"paper.reference_spacing": 1,
}
second = pipeline.transform(first.output_markdown)
assert second.status is RunStatus.SUCCESS
assert second.output_markdown == first.output_markdown
assert second.changes == ()
def test_business_components_are_not_exported_from_core_namespace() -> None:
import mdpolish
assert not hasattr(mdpolish, "WordReviewCommentComponent")
assert not hasattr(mdpolish, "HtmlTableLayoutComponent")
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import pytest
from mdpolish import Pipeline, RunStatus
from mdpolish.components import HtmlTableDoubleEscapeComponent
def transform(markdown: str): # type: ignore[no-untyped-def]
return Pipeline([HtmlTableDoubleEscapeComponent()]).transform(markdown)
def test_unescapes_one_layer_only_in_strict_cell_text() -> None:
markdown = (
'<table data-note="&amp;lt;"><tr><td>&amp;lt;5</td><td>&amp;gt;2 &amp;amp; x &lt;</td></tr></table>'
"\noutside &amp;lt;"
)
result = transform(markdown)
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == (
'<table data-note="&amp;lt;"><tr><td>&lt;5</td><td>&gt;2 &amp; x &lt;</td></tr></table>'
"\noutside &amp;lt;"
)
assert len(result.changes) == 3
def test_multiple_tables_and_cells_report_source_order() -> None:
markdown = "<table><tr><td>&amp;gt;</td></tr></table> x <table><tr><th>&amp;lt;</th></tr></table>"
result = transform(markdown)
assert result.output_markdown == "<table><tr><td>&gt;</td></tr></table> x <table><tr><th>&lt;</th></tr></table>"
assert [change.span.start for change in result.changes] == sorted(change.span.start for change in result.changes)
@pytest.mark.parametrize(
"markdown",
[
"outside &amp;lt;",
"<table><tr><td>&amp;lt;</tr></table>",
"<table><tr><td><em>&amp;lt;</em></td></tr></table>",
"<table><tr><td><table><tr><td>&amp;lt;</td></tr></table></td></tr></table>",
"<table><tbody><tr><td>&amp;lt;</td></tr></tbody></table>",
],
)
def test_non_strict_or_outside_content_is_preserved(markdown: str) -> None:
assert transform(markdown).output_markdown == markdown
def test_fenced_table_is_not_protected() -> None:
markdown = "```html\n<table><tr><td>&amp;lt;</td></tr></table>\n```"
assert transform(markdown).output_markdown == "```html\n<table><tr><td>&lt;</td></tr></table>\n```"
def test_successful_output_is_idempotent() -> None:
pipeline = Pipeline([HtmlTableDoubleEscapeComponent()])
first = pipeline.transform("<table><tr><td>&amp;lt;</td></tr></table>")
assert first.output_markdown is not None
assert pipeline.transform(first.output_markdown).changes == ()
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import pytest
from mdpolish import Pipeline, RunStatus
from mdpolish.components import HtmlTableLayoutComponent
TABLE = '<table class="x"><tr><td colspan="2">A</td></tr><tr><td>B</td><td>C</td></tr></table>'
def transform(markdown: str): # type: ignore[no-untyped-def]
return Pipeline([HtmlTableLayoutComponent()]).transform(markdown)
def test_expands_rows_without_changing_tags_attributes_or_cells() -> None:
result = transform(f"before\n{TABLE}\nafter")
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == (
"before\n<table class=\"x\">\n"
" <tr><td colspan=\"2\">A</td></tr>\n"
" <tr><td>B</td><td>C</td></tr>\n"
"</table>\nafter"
)
assert len(result.changes) == 1
@pytest.mark.parametrize("line_ending", ["\n", "\r\n", "\r"])
def test_uses_the_documents_single_line_ending_style(line_ending: str) -> None:
markdown = f"before{line_ending}{TABLE}{line_ending}after"
result = transform(markdown)
assert result.output_markdown is not None
assert f"<table class=\"x\">{line_ending} <tr>" in result.output_markdown
def test_table_only_document_uses_lf() -> None:
assert transform(TABLE).output_markdown == (
'<table class="x">\n'
' <tr><td colspan="2">A</td></tr>\n'
" <tr><td>B</td><td>C</td></tr>\n"
"</table>"
)
@pytest.mark.parametrize(
"markdown",
[
"before\n<table><tr><td>A</td></tr>\r\nafter",
"<table>\n <tr><td>A</td></tr>\n</table>",
"<table><tr><td>A</tr></table>",
"<table><tbody><tr><td>A</td></tr></tbody></table>",
"<table><tr><td><em>A</em></td></tr></table>",
],
)
def test_mixed_multiline_or_non_strict_tables_are_preserved(markdown: str) -> None:
assert transform(markdown).output_markdown == markdown
def test_successful_output_is_idempotent() -> None:
pipeline = Pipeline([HtmlTableLayoutComponent()])
first = pipeline.transform(TABLE)
assert first.output_markdown is not None
assert pipeline.transform(first.output_markdown).changes == ()
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
import pytest
from mdpolish import Pipeline, RunStatus
from mdpolish.components import ManuscriptLineNumberComponent
def _document(
*,
count: int = 20,
heading_numbers: frozenset[int] = frozenset({5, 15}),
numbers: tuple[int, ...] | None = None,
line_ending: str = "\n",
) -> str:
values = numbers if numbers is not None else tuple(range(1, count + 1))
body = [
f"## {number} Section {number}" if number in heading_numbers else f"{number} body {number}"
for number in values
]
return line_ending.join(("1 Affiliation", "2 Institute", "## Abstract", *body))
def transform(markdown: str): # type: ignore[no-untyped-def]
return Pipeline([ManuscriptLineNumberComponent()]).transform(markdown)
def test_removes_long_monotonic_sequence_but_preserves_pre_abstract_affiliations() -> None:
result = transform(_document())
assert result.status is RunStatus.SUCCESS
assert result.output_markdown is not None
assert result.output_markdown.startswith("1 Affiliation\n2 Institute\n## Abstract\nbody 1")
assert "## Section 5" in result.output_markdown
assert len(result.changes) == 20
@pytest.mark.parametrize("line_ending", ["\n", "\r\n", "\r"])
def test_preserves_all_supported_line_endings(line_ending: str) -> None:
result = transform(_document(line_ending=line_ending))
assert result.output_markdown is not None
assert result.output_markdown.count(line_ending) == _document(line_ending=line_ending).count(line_ending)
def test_allows_skipped_numbers_when_sequence_is_strictly_increasing() -> None:
numbers = tuple(range(10, 30))
result = transform(_document(numbers=numbers, heading_numbers=frozenset({14, 24})))
assert result.status is RunStatus.SUCCESS
assert len(result.changes) == 20
@pytest.mark.parametrize(
"markdown",
[
_document(count=19, heading_numbers=frozenset({5, 15})),
_document(heading_numbers=frozenset({5})),
_document(numbers=(*tuple(range(1, 20)), 10), heading_numbers=frozenset({5, 15})),
_document().replace("## Abstract", "## ABSTRACT"),
_document() + "\n## Abstract",
],
)
def test_incomplete_or_ambiguous_evidence_preserves_the_document(markdown: str) -> None:
result = transform(markdown)
assert result.output_markdown == markdown
assert result.changes == ()
def test_lists_years_and_numbers_inside_body_are_not_candidates() -> None:
markdown = _document() + "\n1. list\n1) list\n2024 report\nThe panel included 35 experts"
result = transform(markdown)
assert result.output_markdown is not None
assert result.output_markdown.endswith("1. list\n1) list\n2024 report\nThe panel included 35 experts")
assert len(result.changes) == 20
def test_successful_output_is_idempotent() -> None:
pipeline = Pipeline([ManuscriptLineNumberComponent()])
first = pipeline.transform(_document())
assert first.output_markdown is not None
second = pipeline.transform(first.output_markdown)
assert second.status is RunStatus.SUCCESS
assert second.changes == ()
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import pytest
from mdpolish import Pipeline, RunStatus
from mdpolish.component import ComponentContractError
from mdpolish.components import PageBreakWordJoinComponent
MAPPINGS = (
("possi-", "bly", "possibly"),
("SOFA-", "based", "SOFA-based"),
("threshold.", "olds", "thresholds"),
)
def transform(markdown: str): # type: ignore[no-untyped-def]
return Pipeline([PageBreakWordJoinComponent(MAPPINGS)]).transform(markdown)
@pytest.mark.parametrize(
("markdown", "expected"),
[
("except possi-\nbly through care", "except possibly through care"),
("except possi-\n\nbly through care", "except possibly through care"),
("use SOFA-\nbased criteria", "use SOFA-based criteria"),
("at threshold.\n\nolds of eight", "at thresholds of eight"),
("except possi-\r\n\r\nbly now", "except possibly now"),
("except possi-\r\rbly now", "except possibly now"),
],
)
def test_applies_exact_mapping_across_approved_line_shapes(markdown: str, expected: str) -> None:
result = transform(markdown)
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == expected
assert len(result.changes) == 1
@pytest.mark.parametrize(
"markdown",
[
"except unknown-\nword here",
"except possi-\n\n\nbly here",
"except possi-\r\n\nbly here",
"except POSSI-\nbly here",
"except possi-\nblymore here",
"except xSOFA-\nbased here",
],
)
def test_unknown_or_unsafe_boundaries_are_preserved(markdown: str) -> None:
assert transform(markdown).output_markdown == markdown
def test_parameters_and_results_are_independent_of_mapping_order() -> None:
first = PageBreakWordJoinComponent(MAPPINGS)
second = PageBreakWordJoinComponent(reversed(MAPPINGS))
markdown = "possibly becomes possi-\nbly"
first_result = Pipeline([first]).transform(markdown)
second_result = Pipeline([second]).transform(markdown)
assert first_result.components == second_result.components
assert first_result.output_markdown == second_result.output_markdown
@pytest.mark.parametrize(
"mappings",
[
(("", "right", "word"),),
(("left", "right", "two words"),),
(("left", "right", "word"), ("left", "right", "other")),
(("left", "right"),),
],
)
def test_invalid_mappings_raise_contract_error(mappings: object) -> None:
with pytest.raises(ComponentContractError):
PageBreakWordJoinComponent(mappings) # type: ignore[arg-type]
def test_successful_output_is_idempotent() -> None:
pipeline = Pipeline([PageBreakWordJoinComponent(MAPPINGS)])
first = pipeline.transform("except possi-\n\nbly here")
assert first.output_markdown is not None
assert pipeline.transform(first.output_markdown).changes == ()
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
import pytest
from mdpolish import Pipeline, RunStatus
from mdpolish.components import ReferenceSpacingComponent
def transform(markdown: str): # type: ignore[no-untyped-def]
return Pipeline([ReferenceSpacingComponent()]).transform(markdown)
def test_normalizes_missing_and_extra_blank_lines_in_references_only() -> None:
markdown = "1. Method\n2. Method\n\n## REFERENCES\n\n1. First\n2. Second\n\n\n3. Third"
result = transform(markdown)
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == "1. Method\n2. Method\n\n## REFERENCES\n\n1. First\n\n2. Second\n\n3. Third"
assert len(result.changes) == 2
@pytest.mark.parametrize("heading", ["# References", "## REFERENCES", "### references", "#### ReFeReNcEs"])
def test_accepts_exact_references_heading_with_ascii_case_folding(heading: str) -> None:
markdown = f"{heading}\n\n1. First\n2. Second"
assert transform(markdown).output_markdown == f"{heading}\n\n1. First\n\n2. Second"
@pytest.mark.parametrize("line_ending", ["\n", "\r\n", "\r"])
def test_preserves_line_ending_style(line_ending: str) -> None:
markdown = line_ending.join(("## References", "", "1. First", "2. Second"))
expected = line_ending.join(("## References", "", "1. First", "", "2. Second"))
assert transform(markdown).output_markdown == expected
@pytest.mark.parametrize(
"markdown",
[
"## Reference\n\n1. First\n2. Second",
"## References\n\n1. First\n3. Third",
"## References\n\n2. Second\n3. Third",
"## References\n\n1. First\n### Subsection\n2. Second",
"## References\r\n\r\n1. First\r\n\n2. Second",
],
)
def test_ambiguous_or_mixed_sections_are_preserved(markdown: str) -> None:
assert transform(markdown).output_markdown == markdown
def test_same_or_higher_heading_ends_section() -> None:
markdown = "## References\n\n1. First\n2. Second\n\n## Appendix\n\n1. Keep\n2. Keep"
result = transform(markdown)
assert result.output_markdown == "## References\n\n1. First\n\n2. Second\n\n## Appendix\n\n1. Keep\n2. Keep"
def test_multiline_reference_uses_its_last_text_line_as_boundary() -> None:
markdown = "## References\n\n1. First line\ncontinuation\n2. Second"
result = transform(markdown)
assert result.output_markdown == "## References\n\n1. First line\ncontinuation\n\n2. Second"
def test_successful_output_is_idempotent() -> None:
pipeline = Pipeline([ReferenceSpacingComponent()])
first = pipeline.transform("## References\n\n1. First\n2. Second")
assert first.output_markdown is not None
assert pipeline.transform(first.output_markdown).changes == ()
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
import pytest
from mdpolish import Pipeline, RunStatus
from mdpolish.components import RepeatedRunningHeaderComponent
HEADER = "## Repeated Paper Header"
def transform(markdown: str): # type: ignore[no-untyped-def]
return Pipeline([RepeatedRunningHeaderComponent()]).transform(markdown)
@pytest.mark.parametrize("line_ending", ["\n", "\r\n", "\r"])
def test_bridges_interrupted_sentence_and_deletes_other_occurrence(line_ending: str) -> None:
markdown = line_ending.join(
(
"Sentence continues in",
"",
HEADER,
"",
"the next line.",
"",
"18. Reference",
"",
HEADER,
"",
"19. Reference",
)
)
result = transform(markdown)
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == line_ending.join(
("Sentence continues in the next line.", "", "18. Reference", "", "19. Reference")
)
assert len(result.changes) == 2
@pytest.mark.parametrize(
"markdown",
[
f"before\n\n{HEADER}\n\nafter",
f"Sentence ends.\n\n{HEADER}\n\nAfter\n\nAnother sentence.\n\n{HEADER}\n\nOther",
f"Sentence continues\n\n{HEADER}\n\nAfter\n\nText ends.\n\n{HEADER}\n\nOther",
f"Sentence continues\n\n{HEADER}\n\nafter\n{HEADER}\nnot blank",
],
)
def test_missing_or_unsafe_group_evidence_preserves_document(markdown: str) -> None:
assert transform(markdown).output_markdown == markdown
def test_header_matching_is_exact_and_not_keyword_based() -> None:
markdown = "continues\n\n## Any Header\n\nfrom here\n\n18. Ref\n\n## Any Header\n\n19. Ref"
result = transform(markdown)
assert result.output_markdown == "continues from here\n\n18. Ref\n\n19. Ref"
def test_successful_output_is_idempotent() -> None:
pipeline = Pipeline([RepeatedRunningHeaderComponent()])
first = pipeline.transform(f"continues\n\n{HEADER}\n\nfrom here\n\ntext\n\n{HEADER}\n\nend")
assert first.output_markdown is not None
assert pipeline.transform(first.output_markdown).changes == ()
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
import pytest
from mdpolish import Pipeline, RunStatus
from mdpolish.components import WordReviewCommentComponent
def transform(markdown: str): # type: ignore[no-untyped-def]
return Pipeline([WordReviewCommentComponent()]).transform(markdown)
def test_deletes_single_line_comment_and_one_following_blank() -> None:
markdown = "before\nCommented [AB1]: review this\n\nafter"
result = transform(markdown)
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == "before\nafter"
assert len(result.changes) == 1
assert result.changes[0].reason == "删除严格单行 Word 审阅批注及其后一个空行"
def test_adjacent_comment_blocks_are_deleted_without_overlap() -> None:
markdown = "before\nCommented [A1]: first\n\nCommented [B2R1]: second\n\nafter"
result = transform(markdown)
assert result.output_markdown == "before\nafter"
assert len(result.changes) == 2
assert result.changes[0].span.end == result.changes[1].span.start
@pytest.mark.parametrize("line_ending", ["\n", "\r\n", "\r"])
def test_preserves_line_ending_style(line_ending: str) -> None:
markdown = line_ending.join(("before", "Commented [A1]: note", "", "after"))
assert transform(markdown).output_markdown == line_ending.join(("before", "after"))
@pytest.mark.parametrize(
"comment",
[
"prefix Commented [A1]: note",
" Commented [A1]: note",
"Commented []: note",
"Commented [A-1]: note",
"Commented [A1]:",
"Commented [A1]: ",
],
)
def test_similar_lines_are_preserved(comment: str) -> None:
markdown = f"before\n{comment}\n\nafter"
assert transform(markdown).output_markdown == markdown
def test_requires_a_following_blank_line_and_does_not_delete_extra_blanks() -> None:
without_blank = "Commented [A1]: note\nafter"
with_two_blanks = "before\nCommented [A1]: note\n\n\nafter"
assert transform(without_blank).output_markdown == without_blank
assert transform(with_two_blanks).output_markdown == "before\n\nafter"
def test_successful_output_is_idempotent() -> None:
pipeline = Pipeline([WordReviewCommentComponent()])
first = pipeline.transform("Commented [A1]: note\n\nafter")
assert first.output_markdown is not None
second = pipeline.transform(first.output_markdown)
assert second.status is RunStatus.SUCCESS
assert second.changes == ()