Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions paper_format_agent/scorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
is_english_abstract_title,
is_keyword_en,
is_keyword_zh,
is_table_caption,
looks_like_toc_entry,
is_toc_title,
normalize_text,
Expand Down Expand Up @@ -129,6 +130,12 @@
"summary": "Document length is below the minimum required by the format guide.",
"suggested_fix": "Verify whether the format guide's minimum character count applies to this manuscript.",
},
"missing_table_caption": {
"category": "caption",
"severity": "medium",
"summary": "A table has no table caption on the paragraph directly above it.",
"suggested_fix": "Add a numbered table caption line immediately above each table, for example '表 1 ...' or 'Table 1. ...', matching the numbering style required by the format guide.",
},
}


Expand Down Expand Up @@ -182,6 +189,7 @@ def _diagnostic_evidence(name: str, features: dict[str, Any]) -> dict[str, Any]:
"blank_page_risk": ["blank_page_risk"],
"content_loss_vs_baseline": ["baseline_chars_no_space", "chars_ratio_vs_baseline"],
"char_below_min": ["chars_no_space", "min_chars"],
"missing_table_caption": ["table_caption_evidence"],
}
return {key: features.get(key) for key in keys_by_name.get(name, [])}

Expand Down Expand Up @@ -307,6 +315,49 @@ def _toc_heading_leak(doc: Document, idx_toc: int | None) -> int:
return heading_like


def _table_caption_evidence(doc: Document) -> list[dict[str, Any]]:
"""
For each top-level table in the document, check whether the nearest
preceding non-empty paragraph reads as a table caption. Returns one
evidence entry per table that lacks such a caption.
"""
from docx.oxml.ns import qn

body = doc.element.body
order: list[tuple[str, int]] = []
p_idx = 0
t_idx = 0
for child in body.iterchildren():
if child.tag == qn("w:p"):
order.append(("p", p_idx))
p_idx += 1
elif child.tag == qn("w:tbl"):
order.append(("tbl", t_idx))
t_idx += 1

paragraphs = list(doc.paragraphs)
issues: list[dict[str, Any]] = []
for i, (kind, num) in enumerate(order):
if kind != "tbl":
continue
nearest_p: tuple[int, str] | None = None
for j in range(i - 1, -1, -1):
prev_kind, prev_num = order[j]
if prev_kind != "p":
continue
text = (paragraphs[prev_num].text or "").strip()
if text:
nearest_p = (prev_num, text)
break
if nearest_p is None:
issues.append({"table_index": num, "idx": None, "text": ""})
continue
prev_num, prev_text = nearest_p
if not is_table_caption(prev_text):
issues.append({"table_index": num, "idx": prev_num, "text": prev_text[:60]})
return issues


def score_document(
docx_path: str | Path,
rules: dict,
Expand Down Expand Up @@ -368,6 +419,7 @@ def score_document(
blank_risk = _page_break_blank_risk(doc)
prefix_toc = _prefix_toc_stats(doc, idx_abs)
toc_heading_leak = _toc_heading_leak(doc, idx_toc)
table_caption_evidence = _table_caption_evidence(doc)

penalties: list[dict[str, Any]] = []

Expand Down Expand Up @@ -405,6 +457,10 @@ def score_document(
penalties.append({"name": "toc_heading_leak", "value": min(24, toc_heading_leak * 2)})
if blank_risk > 0:
penalties.append({"name": "blank_page_risk", "value": min(15, blank_risk * 3)})
if table_caption_evidence:
penalties.append(
{"name": "missing_table_caption", "value": min(20, len(table_caption_evidence) * 6)}
)

# Default mode checks content preservation, not absolute length thresholds.
if baseline_chars is not None and baseline_chars > 0:
Expand Down Expand Up @@ -468,6 +524,7 @@ def score_document(
"prefix_toc_like_ratio_before_abs": round(float(prefix_toc["toc_like_ratio"]), 3),
"prefix_manual_toc_before_abs": bool(prefix_toc["is_manual_toc"]),
"min_chars": min_chars,
"table_caption_evidence": table_caption_evidence,
}

return {
Expand Down
53 changes: 53 additions & 0 deletions tests/test_scorer_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,59 @@ def test_save_reports_writes_diagnostics_to_json_and_html(self):
self.assertIn("Suggested fix", html)
self.assertIn("missing_zh_abs", html)

def test_table_without_caption_triggers_missing_table_caption(self):
with TemporaryDirectory() as td:
docx_path = Path(td) / "table_no_caption.docx"
doc = Document()
doc.add_paragraph("The experimental results are shown below.")
table = doc.add_table(rows=2, cols=2)
table.cell(0, 0).text = "method"
table.cell(0, 1).text = "accuracy"
doc.save(docx_path)

report = score_document(docx_path, {"min_total_chars_no_space": 0})
diagnostics = {item["name"]: item for item in report["diagnostics"]}

self.assertIn("missing_table_caption", diagnostics)
diag = diagnostics["missing_table_caption"]
self.assertEqual(diag["severity"], "medium")
self.assertTrue(diag["suggested_fix"])
self.assertGreaterEqual(diag["penalty"], 6)
self.assertGreaterEqual(report["penalties"][0]["value"], 6)
evidence = diag["evidence"]["table_caption_evidence"]
self.assertEqual(len(evidence), 1)
self.assertEqual(evidence[0]["table_index"], 0)
self.assertIn("idx", evidence[0])
self.assertIn("text", evidence[0])

def test_table_with_caption_does_not_trigger_diagnostic(self):
with TemporaryDirectory() as td:
base_rules = {"min_total_chars_no_space": 0}
captioned_path = Path(td) / "captioned.docx"
bare_path = Path(td) / "bare.docx"

captioned = Document()
captioned.add_paragraph("Table 1: Experimental results")
table = captioned.add_table(rows=2, cols=2)
table.cell(0, 0).text = "method"
table.cell(0, 1).text = "accuracy"
captioned.save(captioned_path)

bare = Document()
bare.add_paragraph("The experimental results are shown below.")
table = bare.add_table(rows=2, cols=2)
table.cell(0, 0).text = "method"
table.cell(0, 1).text = "accuracy"
bare.save(bare_path)

captioned_report = score_document(captioned_path, base_rules)
bare_report = score_document(bare_path, base_rules)

names = {item["name"] for item in captioned_report["diagnostics"]}
self.assertNotIn("missing_table_caption", names)
self.assertNotIn("missing_table_caption", captioned_report["penalties"])
self.assertGreater(captioned_report["score"], bare_report["score"])


if __name__ == "__main__":
unittest.main()