refactor: make the abstract a paragraph instead of a paper field

A template's abstract paragraph (`0 Abstract`) *is* the paper's abstract: it has
a position in the document, the heading and typography the template gives it,
and the same sentence-by-sentence editing as everything else. `paper.abstract`
was a second home for that text — one the document never reads, so a paper
could show two different abstracts and the column could drift from the body.

The column is gone, from the table, the model, the schemas, the API payloads,
the paper form, the list subtitle and the paper page. Nothing else changed.

The text already written into it is not gone. Revision a83f5c21d7b6 writes each
stored abstract into the paper's body first — one sentence per 。 at the
paragraph carrying the abstract heading, appended after anything already there
rather than replacing it. A template without such a heading keeps the text too,
one position above its first paragraph, where the document renders it under
未设定. Verified against the one paper that had an abstract: 450 characters in,
450 out, identical including `|J| ≤ α · I⁻ᵝ`, split into six sentences in the
`0 Abstract` paragraph, still with its own edit button.

The smoke test no longer assumes an empty database: it records the paper total
before it starts and compares against that, so it can run on a real one.
This commit is contained in:
2026-09-18 18:55:10 +08:00
parent 4e2e3abc30
commit 45926af966
10 changed files with 192 additions and 54 deletions
@@ -0,0 +1,148 @@
"""move a paper's abstract into its body, then drop the column
``paper.abstract`` was a second home for something the outline already has a
place for. A template's abstract paragraph (``0 Abstract`` in the seeded
library) *is* the paper's abstract — it has a position in the document, a
heading the template styles, and it sits in the same order as everything else.
A separate column put the same text somewhere the document never reads, so a
paper could show two different abstracts, or an abstract that no longer matched
the paper it belonged to.
The column is therefore dropped. The text in it is not: this revision writes
each stored abstract into the paper's body first, one sentence per ``。``, at
the paragraph that carries the abstract heading. Papers whose template has no
such heading keep their text too — it goes to the position just above the first
paragraph, where the document renders it under 未设定 rather than discarding it.
Revision ID: a83f5c21d7b6
Revises: f27a1c6d9e04
Create Date: 2026-09-18
"""
import re
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a83f5c21d7b6"
down_revision: str | None = "f27a1c6d9e04"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
#: Split *after* each full stop, so the stop stays with the sentence it ends.
#: An abstract with no full stop at all stays one sentence, which is what a
#: one-line abstract is.
_SENTENCE_BREAK = re.compile(r"(?<=。)")
#: The heading that means "this paragraph is the abstract". Matched on either
#: language because the library is user-editable and both spellings are in use.
_ABSTRACT_HEADING = "(LOWER(name) LIKE '%abstract%' OR name LIKE '%摘要%')"
def _split_sentences(text: str) -> list[str]:
"""One line per sentence, with whitespace folded as the API folds it."""
parts = (" ".join(part.split()) for part in _SENTENCE_BREAK.split(text.strip()))
return [part for part in parts if part]
def _target_position(bind: sa.Connection, template_id: int | None) -> int:
"""Where the abstract should land in the paper's document.
The template's abstract paragraph when it has one; otherwise the position
just before the first paragraph, so the text keeps its place at the top of
the document even though no heading describes it; otherwise 1.
"""
if template_id is None:
return 1
row = bind.execute(
sa.text(
f"""
SELECT tf.sort
FROM template_field AS tf
JOIN template_field_library AS lib ON lib.id = tf.field_id
WHERE tf.template_id = :template_id AND {_ABSTRACT_HEADING}
ORDER BY tf.sort ASC, tf.id ASC
LIMIT 1
"""
),
{"template_id": template_id},
).first()
if row is not None:
return int(row[0])
first = bind.execute(
sa.text(
"SELECT MIN(sort) FROM template_field WHERE template_id = :template_id"
),
{"template_id": template_id},
).scalar()
return int(first) - 1 if first is not None else 1
def upgrade() -> None:
bind = op.get_bind()
papers = bind.execute(
sa.text(
"""
SELECT id, template_id, abstract
FROM paper
WHERE abstract IS NOT NULL AND TRIM(abstract) <> ''
"""
)
).all()
for paper_id, template_id, abstract in papers:
sentences = _split_sentences(abstract)
if not sentences:
continue
position = _target_position(bind, template_id)
# Appended rather than replacing: anything already written in that
# paragraph is the writer's, and losing it to a migration would be a
# far worse outcome than a duplicate they can delete in one click.
start = bind.execute(
sa.text(
"""
SELECT COALESCE(MAX(sort), 0)
FROM paper_sentence
WHERE paper_id = :paper_id AND paper_template_filed_sort = :position
"""
),
{"paper_id": paper_id, "position": position},
).scalar() or 0
for offset, content in enumerate(sentences, start=1):
bind.execute(
sa.text(
"""
INSERT INTO paper_sentence
(paper_id, template_id, paper_template_filed_sort, sort, content)
VALUES
(:paper_id, :template_id, :position, :sort, :content)
"""
),
{
"paper_id": paper_id,
"template_id": template_id,
"position": position,
"sort": int(start) + offset,
"content": content,
},
)
op.drop_column("paper", "abstract")
def downgrade() -> None:
"""Re-add the column, empty.
The text is not moved back: it is body content now, and the column it came
from is the thing this revision exists to remove. Reverting the model is
enough to leave the column unused again.
"""
op.add_column("paper", sa.Column("abstract", sa.Text(), nullable=True))