Files
govin e582198cc8 backend: decide the seam between sentences instead of gluing them
A paragraph is its sentences concatenated. Nothing was put between them, which
is right for Chinese — 「。」 already separates — and wrong for English, where
`Adaptive capacity rises.` followed by `Relocation follows.` printed as
`...rises.Relocation...`: a sentence boundary the reader cannot see. CJK output
hid it, so it would have surfaced as an English bug later rather than now.

`sentence_separator` now returns "" or a single space per seam, and the document
carries it as `SentenceRead.separator_before`. The rule is about the seam, not
the language: a space goes in unless both sides are CJK. Mixed seams take the
space. Empty sentences take none. It is derived on every read and never stored,
so it cannot drift from the text, and the client prints `separator_before +
content` and adds no spacing of its own.

No splitting was added anywhere, and none exists: a sentence is one line in the
editor and nothing parses it. The single split this project has ever performed
was the one-time move of the old abstract column, which cut after 「。」 only —
conservative on purpose, since an English abstract is better left in one row
than cut at the first `et al.`.

Five smoke checks cover the seam (Chinese, English, the assembled paragraph,
and that no separator is stored inside the content). 45 checks pass.
2026-09-18 19:01:31 +08:00

379 lines
16 KiB
Python

"""End-to-end smoke test for the paper feature, against a running API.
Run it while the backend is up::
.venv/bin/python scripts/smoke_papers.py
It walks the whole writing loop — create a paper on a template, verify the
empty structure, fill a paragraph with sentences and citations, read the
document back, switch the template, and check that unmatched positions survive
under an unset heading — then deletes everything it created. Exits non-zero on
the first failed expectation, so it is usable as a gate.
"""
from __future__ import annotations
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
BASE = "http://127.0.0.1:8000/api"
_checks = 0
def call(method: str, path: str, body: Any = None, expect: int = 200) -> Any:
"""Perform one API call and assert its status code.
The path is quoted so a Chinese search keyword travels as UTF-8 percent
escapes instead of blowing up the ASCII request line.
"""
data = json.dumps(body).encode() if body is not None else None
url = BASE + urllib.parse.quote(path, safe="/?&=%")
request = urllib.request.Request(
url,
data=data,
method=method,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
status = response.status
payload = response.read().decode()
except urllib.error.HTTPError as error:
status = error.code
payload = error.read().decode()
if status != expect:
raise AssertionError(f"{method} {path} -> {status}, expected {expect}: {payload}")
return json.loads(payload) if payload else None
def check(label: str, condition: bool, detail: str = "") -> None:
"""Assert one expectation and report it."""
global _checks
_checks += 1
if not condition:
raise AssertionError(f"{label}: {detail}")
print(f" ok {label}")
def main() -> int:
created_papers: list[int] = []
try:
# The database may already hold real papers, so "nothing of ours is
# left" is measured against a baseline rather than against zero.
baseline = call("GET", "/papers")["total"]
print(f"0. baseline: {baseline} paper(s) already in the database")
print("1. pick two templates with different paragraph positions")
templates = call("GET", "/templates?page_size=50")["items"]
check("templates exist", len(templates) >= 2, f"got {len(templates)}")
wide = max(templates, key=lambda item: item["field_count"])
narrow = min(templates, key=lambda item: item["field_count"])
wide_full = call("GET", f"/templates/{wide['id']}")
narrow_full = call("GET", f"/templates/{narrow['id']}")
print(f" wide={wide['name']} ({wide['field_count']}), narrow={narrow['name']}")
print("2. create a paper")
paper = call(
"POST",
"/papers",
{
"title": "冒烟测试论文",
"template_id": wide["id"],
"author": "测试作者",
"status": "writing",
"keywords": "关键词A,关键词B;关键词A",
"target_journal": "测试期刊",
},
expect=201,
)
created_papers.append(paper["id"])
check("keywords normalised", paper["keywords"] == "关键词A, 关键词B", paper["keywords"])
check(
"paragraph denominator counts the template",
paper["template_paragraph_count"] == len({f["sort"] for f in wide_full["fields"]}),
str(paper["template_paragraph_count"]),
)
check("no sentences yet", paper["sentence_count"] == 0)
print("3. the empty paper already has its structure")
document = call("GET", f"/papers/{paper['id']}/document")
check(
"every template position is a paragraph",
len(document["paragraphs"]) == len({f["sort"] for f in wide_full["fields"]}),
str(len(document["paragraphs"])),
)
check("all paragraphs empty", all(not p["sentences"] for p in document["paragraphs"]))
check("all paragraphs matched", all(p["matched"] for p in document["paragraphs"]))
check("no citations", document["citations"] == [])
first_sort = document["paragraphs"][0]["paper_template_filed_sort"]
print("4. write a paragraph: two sentences, citations on both")
document = call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{first_sort}",
{
"sentences": [
{
"content": " 第一句话,用于验证空白折叠。 ",
"citations": [{"reference_id": 7, "quote": "被引用的第一段内容"}],
},
{
"content": "第二句话。",
"citations": [
{"reference_id": 9, "quote": "引文甲"},
{"quote": "只有引用内容、暂时没有引用 id"},
],
},
]
},
)
paragraph = document["paragraphs"][0]
check("two sentences", len(paragraph["sentences"]) == 2, str(len(paragraph["sentences"])))
check("content trimmed", paragraph["sentences"][0]["content"] == "第一句话,用于验证空白折叠。")
check(
"sorts numbered from 1",
[s["sort"] for s in paragraph["sentences"]] == [1, 2],
str([s["sort"] for s in paragraph["sentences"]]),
)
check("citations numbered", [c["sort"] for c in paragraph["sentences"][1]["citations"]] == [1, 2])
check("three citations in the paper", len(document["citations"]) == 3)
check(
"citation index is reading order",
[c["index"] for c in document["citations"]] == [1, 2, 3],
str([c["index"] for c in document["citations"]]),
)
check("sentence template stamped", paragraph["sentences"][0]["template_id"] == wide["id"])
print("5. explicit sorts decide the order, not the order they were sent")
second_sort = document["paragraphs"][1]["paper_template_filed_sort"]
reordered = call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{second_sort}",
{
"sentences": [
{"sort": 20, "content": "排序在后的句子。"},
{"sort": 10, "content": "排序在前的句子。"},
]
},
)
second = next(
p
for p in reordered["paragraphs"]
if p["paper_template_filed_sort"] == second_sort
)
check(
"ascending sort wins over arrival order",
[s["content"] for s in second["sentences"]]
== ["排序在前的句子。", "排序在后的句子。"],
str([s["content"] for s in second["sentences"]]),
)
check("explicit sorts are stored as sent", [s["sort"] for s in second["sentences"]] == [10, 20])
# Clear it again so the counts asserted below stay meaningful.
call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{second_sort}",
{"sentences": []},
)
print("6. the seam between sentences depends on the language")
# Chinese needs nothing after 。; English needs a space, or two
# sentences print as `First.Second.`
mixed = call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{second_sort}",
{
"sentences": [
{"content": "第一句。"},
{"content": "第二句。"},
{"content": "This is English."},
{"content": "So is this."},
]
},
)
paragraph_mixed = next(
p
for p in mixed["paragraphs"]
if p["paper_template_filed_sort"] == second_sort
)
separators = [s["separator_before"] for s in paragraph_mixed["sentences"]]
check("no space between Chinese sentences", separators[:2] == ["", ""], str(separators))
check("a space before an English sentence", separators[2] == " ", str(separators))
check("a space between English sentences", separators[3] == " ", str(separators))
assembled = "".join(
s["separator_before"] + s["content"] for s in paragraph_mixed["sentences"]
)
check(
"the assembled paragraph reads correctly",
assembled == "第一句。第二句。 This is English. So is this.",
assembled,
)
check(
"the separator is not part of the stored text",
all(
not s["content"].startswith(" ") and not s["content"].endswith(" ")
for s in paragraph_mixed["sentences"]
),
)
# Clear it again so the counts asserted below stay meaningful.
call("PUT", f"/papers/{paper['id']}/paragraphs/{second_sort}", {"sentences": []})
print("7. reject a citation with no quote")
bad = call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{first_sort}",
{"sentences": [{"content": "x", "citations": [{"reference_id": 3, "quote": " "}]}]},
expect=422,
)
check("citation without content refused", "detail" in bad)
print("8. a sentence with no paragraph in the template survives a switch")
# A position neither template defines, so the check means the same
# thing before and after the switch below.
orphan_sort = (
max(f["sort"] for f in wide_full["fields"] + narrow_full["fields"]) + 5
)
call(
"POST",
f"/papers/{paper['id']}/sentences",
{"paper_template_filed_sort": orphan_sort, "content": "新模板里没有这个位置的句子。"},
expect=201,
)
document = call("GET", f"/papers/{paper['id']}/document")
check(
"orphan position is rendered",
any(
p["paper_template_filed_sort"] == orphan_sort and not p["matched"]
for p in document["paragraphs"]
),
)
check("orphan heading has no name", next(
p for p in document["paragraphs"] if p["paper_template_filed_sort"] == orphan_sort
)["name"] is None)
print("9. switch the template")
switched = call("PATCH", f"/papers/{paper['id']}", {"template_id": narrow["id"]})
check("template changed", switched["template_id"] == narrow["id"])
document = call("GET", f"/papers/{paper['id']}/document")
matched = [p for p in document["paragraphs"] if p["matched"]]
unmatched = [p for p in document["paragraphs"] if not p["matched"]]
check(
"structure follows the new template",
len(matched) == len({f["sort"] for f in narrow_full["fields"]}),
str(len(matched)),
)
check("unmatched positions kept", len(unmatched) >= 1, str(len(unmatched)))
check(
"all sentences still present",
sum(len(p["sentences"]) for p in document["paragraphs"]) == 3,
)
check(
"positions ascend",
[p["paper_template_filed_sort"] for p in document["paragraphs"]]
== sorted(p["paper_template_filed_sort"] for p in document["paragraphs"]),
)
check(
"sentences re-stamped with the new template",
all(
s["template_id"] == narrow["id"]
for p in document["paragraphs"]
for s in p["sentences"]
),
)
print("10. move one paragraph onto another position")
document = call(
"PUT",
f"/papers/{paper['id']}/paragraphs/{orphan_sort}",
{"sentences": [{"content": "搬到别处的一句。"}], "target_sort": first_sort},
)
target = next(
p for p in document["paragraphs"] if p["paper_template_filed_sort"] == first_sort
)
check("moved sentence appended after the existing ones", len(target["sentences"]) == 3)
check(
"source position gone",
all(p["paper_template_filed_sort"] != orphan_sort for p in document["paragraphs"]),
)
print("11. paper list reports progress")
listing = call("GET", f"/papers?keyword=冒烟&status=writing")
check("found by keyword and status", listing["total"] == 1, str(listing["total"]))
row = listing["items"][0]
check("paragraph count", row["paragraph_count"] == 1, str(row["paragraph_count"]))
check("sentence count", row["sentence_count"] == 3, str(row["sentence_count"]))
print("12. a template in use cannot be deleted")
conflict = call("DELETE", f"/templates/{narrow['id']}", expect=409)
check("template delete refused", "正被论文使用" in conflict["detail"], conflict["detail"])
print("13. single sentence edit and delete")
sentence_id = target["sentences"][0]["id"]
updated = call(
"PATCH",
f"/papers/{paper['id']}/sentences/{sentence_id}",
{"content": "改写后的一句话。", "sort": 5},
)
check("sentence updated", updated["content"] == "改写后的一句话。")
check("sentence sort updated", updated["sort"] == 5)
call("DELETE", f"/papers/{paper['id']}/sentences/{sentence_id}", expect=204)
document = call("GET", f"/papers/{paper['id']}/document")
check(
"sentence deleted",
sum(len(p["sentences"]) for p in document["paragraphs"]) == 2,
)
print("14. a paper with no template still works")
bare = call(
"POST",
"/papers",
{"title": "冒烟测试-无模板论文", "template_id": None, "status": "draft"},
expect=201,
)
created_papers.append(bare["id"])
empty_doc = call("GET", f"/papers/{bare['id']}/document")
check("no template means no structure", empty_doc["paragraphs"] == [])
call(
"POST",
f"/papers/{bare['id']}/sentences",
{"paper_template_filed_sort": 1, "content": "没有模板时写下的第一句。"},
expect=201,
)
bare_doc = call("GET", f"/papers/{bare['id']}/document")
check("content creates its own paragraph", len(bare_doc["paragraphs"]) == 1)
check("that paragraph has no heading", bare_doc["paragraphs"][0]["matched"] is False)
check("and the reader is warned", len(bare_doc["warnings"]) >= 1)
call("DELETE", f"/papers/{bare['id']}", expect=204)
created_papers.remove(bare["id"])
print("15. delete the paper")
call("DELETE", f"/papers/{paper['id']}", expect=204)
created_papers.clear()
check(
"gone from the list",
call("GET", "/papers")["total"] == baseline,
f"expected {baseline} paper(s) to remain",
)
print(f"\nall {_checks} checks passed")
return 0
finally:
# Never leave test rows behind, even on a failed expectation.
for paper_id in created_papers:
try:
call("DELETE", f"/papers/{paper_id}", expect=204)
print(f"cleaned up paper {paper_id}")
except Exception as error: # noqa: BLE001 - cleanup must not mask the failure
print(f"cleanup of paper {paper_id} failed: {error}", file=sys.stderr)
if __name__ == "__main__":
sys.exit(main())