From 45926af966d4470b97f4201c33874a6c3e363e52 Mon Sep 17 00:00:00 2001 From: govin Date: Fri, 18 Sep 2026 18:55:10 +0800 Subject: [PATCH] refactor: make the abstract a paragraph instead of a paper field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...1d7b6_move_paper_abstract_into_the_body.py | 148 ++++++++++++++++++ backend/app/crud/paper.py | 4 +- backend/app/models/paper.py | 11 +- backend/app/schemas/paper.py | 11 +- backend/scripts/smoke_papers.py | 12 +- docs/OVERVIEW.md | 13 +- frontend/src/api/papers.ts | 12 +- .../src/components/papers/PaperFormDialog.vue | 13 -- frontend/src/views/PapersView.vue | 13 -- frontend/src/views/papers/PaperDetailView.vue | 9 -- 10 files changed, 192 insertions(+), 54 deletions(-) create mode 100644 backend/alembic/versions/a83f5c21d7b6_move_paper_abstract_into_the_body.py diff --git a/backend/alembic/versions/a83f5c21d7b6_move_paper_abstract_into_the_body.py b/backend/alembic/versions/a83f5c21d7b6_move_paper_abstract_into_the_body.py new file mode 100644 index 0000000..96e2f93 --- /dev/null +++ b/backend/alembic/versions/a83f5c21d7b6_move_paper_abstract_into_the_body.py @@ -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)) diff --git a/backend/app/crud/paper.py b/backend/app/crud/paper.py index ce06228..5416c9d 100644 --- a/backend/app/crud/paper.py +++ b/backend/app/crud/paper.py @@ -224,7 +224,7 @@ def read(paper: Paper) -> PaperRead: else 0 ), ) - return PaperRead(**item.model_dump(), abstract=paper.abstract) + return PaperRead(**item.model_dump()) # --- document assembly ------------------------------------------------------- @@ -425,7 +425,7 @@ def update( ``values`` holds only the keys the client actually sent — the route derives it from ``model_fields_set``, which is the only way to tell "clear the - abstract" apart from "leave it alone"; both arrive as ``None``. + author" apart from "leave it alone"; both arrive as ``None``. When the template changes, every sentence is re-stamped with the new template id. The sentences themselves keep their positions, which is what diff --git a/backend/app/models/paper.py b/backend/app/models/paper.py index b4d23f7..e43a88b 100644 --- a/backend/app/models/paper.py +++ b/backend/app/models/paper.py @@ -25,7 +25,7 @@ without a migration. from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, Integer, String, Text, text +from sqlalchemy import ForeignKey, Integer, String, text from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base @@ -69,9 +69,6 @@ class Paper(TimestampMixin, Base): nullable=True, ) - #: 摘要 — the paper's own abstract, free text. - abstract: Mapped[str | None] = mapped_column(Text, nullable=True) - #: 作者. Free text, since authorship is written as it will be printed. author: Mapped[str | None] = mapped_column(String(255), nullable=True) @@ -91,6 +88,12 @@ class Paper(TimestampMixin, Base): #: 投稿目标期刊. target_journal: Mapped[str | None] = mapped_column(String(255), nullable=True) + #: There is deliberately no ``abstract`` column. A paper's abstract is a + #: paragraph of its body — the template's ``0 Abstract`` field — so it has a + #: position in the document, the template's own heading, and the same + #: sentence-by-sentence editing as everything else. A column here would be a + #: second home for the same text, and the document never reads it. + #: Eager-loaded with the paper: every read of a paper shows its template #: name, and a lazy load there would be one query per row in the table. template: Mapped["Template | None"] = relationship(lazy="joined") diff --git a/backend/app/schemas/paper.py b/backend/app/schemas/paper.py index 7903cdb..8dc5937 100644 --- a/backend/app/schemas/paper.py +++ b/backend/app/schemas/paper.py @@ -217,7 +217,6 @@ class PaperBase(BaseModel): title: str = Field(min_length=1, max_length=255) template_id: int | None = None - abstract: str | None = None author: str | None = Field(default=None, max_length=255) status: PaperStatus = "draft" keywords: str | None = Field(default=None, max_length=255) @@ -264,7 +263,6 @@ class PaperUpdate(BaseModel): title: str | None = Field(default=None, min_length=1, max_length=255) template_id: int | None = None - abstract: str | None = None author: str | None = Field(default=None, max_length=255) status: PaperStatus | None = None keywords: str | None = Field(default=None, max_length=255) @@ -317,9 +315,14 @@ class PaperListItem(BaseModel): class PaperRead(PaperListItem): - """A paper with everything the table does not need.""" + """A paper as its own page needs it. - abstract: str | None + Kept separate from :class:`PaperListItem` even though it currently adds no + field, because the two are different contracts: the table's row and the + page's subject. The paper's abstract is not here — it is a paragraph of the + document (see ``app.crud.paper.build_document``), not a property of the + paper. + """ class PaperDocumentRead(BaseModel): diff --git a/backend/scripts/smoke_papers.py b/backend/scripts/smoke_papers.py index 1ccead7..9de0d06 100644 --- a/backend/scripts/smoke_papers.py +++ b/backend/scripts/smoke_papers.py @@ -65,6 +65,11 @@ 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)}") @@ -85,7 +90,6 @@ def main() -> int: "status": "writing", "keywords": "关键词A,关键词B;关键词A", "target_journal": "测试期刊", - "abstract": "用于验证论文功能的临时数据。", }, expect=201, ) @@ -310,7 +314,11 @@ def main() -> int: print("14. delete the paper") call("DELETE", f"/papers/{paper['id']}", expect=204) created_papers.clear() - check("gone from the list", call("GET", "/papers")["total"] == 0) + 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 diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index e602773..21827cf 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -72,8 +72,7 @@ template_field a field placed in a template — and the only field_id → template_field_library, sort paper the document - id, title, template_id, abstract, author, status, keywords, - target_journal + id, title, template_id, author, status, keywords, target_journal paper_sentence one sentence, at one position, in one paper id, paper_id → paper, template_id, paper_template_filed_sort, sort, content @@ -82,6 +81,16 @@ paper_sentence_reference the citations of one sentence id, sentence_id → paper_sentence, reference_id, quote, sort ``` +`paper` has no `abstract` column, and that is a decision rather than an +omission. A paper's abstract *is* a paragraph of its body — the template's +`0 Abstract` field — so it has a position in the document, the heading and +typography the template gives it, and the same sentence-by-sentence editing as +every other paragraph. A column for it would be a second home for the same +text, one the document never reads: a paper could show two different abstracts, +and the one in the column could drift from the one in the body. Revision +`a83f5c21d7b6` removed it, moving each stored abstract into the abstract +paragraph first. + `template_field` and `template_field_library` are one word apart and mean opposite things. The first is a *placement* — this template puts this field here, `sort` included. The second is the *catalogue* the field was picked from, diff --git a/frontend/src/api/papers.ts b/frontend/src/api/papers.ts index 02e58a6..643acd0 100644 --- a/frontend/src/api/papers.ts +++ b/frontend/src/api/papers.ts @@ -56,10 +56,13 @@ export interface PaperListItem { updated_at: string } -/** A paper with everything the table does not need. */ -export interface PaperDetail extends PaperListItem { - abstract: string | null -} +/** + * A paper as its own page needs it. + * + * There is no `abstract` here on purpose: a paper's abstract is the template's + * `0 Abstract` paragraph, written and edited like any other paragraph. + */ +export interface PaperDetail extends PaperListItem {} /** A stored citation of one sentence. */ export interface Citation { @@ -143,7 +146,6 @@ export interface SentenceInput { export interface PaperPayload { title: string template_id: number | null - abstract: string | null author: string | null status: PaperStatus keywords: string | null diff --git a/frontend/src/components/papers/PaperFormDialog.vue b/frontend/src/components/papers/PaperFormDialog.vue index dc479ce..b299dce 100644 --- a/frontend/src/components/papers/PaperFormDialog.vue +++ b/frontend/src/components/papers/PaperFormDialog.vue @@ -53,7 +53,6 @@ const templates = ref([]) const form = reactive({ title: '', template_id: null as number | null, - abstract: '', author: '', status: 'draft' as PaperStatus, keywords: [] as string[], @@ -78,7 +77,6 @@ watch( formRef.value?.clearValidate() form.title = props.paper?.title ?? '' form.template_id = props.paper?.template_id ?? null - form.abstract = '' form.author = props.paper?.author ?? '' form.status = props.paper?.status ?? 'draft' form.keywords = splitKeywords(props.paper?.keywords) @@ -98,7 +96,6 @@ watch( const detail = await getPaper(props.paper.id) form.title = detail.title form.template_id = detail.template_id - form.abstract = detail.abstract ?? '' form.author = detail.author ?? '' form.status = detail.status form.keywords = splitKeywords(detail.keywords) @@ -121,7 +118,6 @@ async function submit(): Promise { const payload = { title: form.title.trim(), template_id: form.template_id, - abstract: form.abstract.trim() || null, author: form.author.trim() || null, status: form.status, keywords: form.keywords.length ? form.keywords.join(', ') : null, @@ -226,15 +222,6 @@ async function submit(): Promise { - - - - @@ -390,16 +387,6 @@ onMounted(async () => { text-align: left; } -.abstract { - margin-top: 2px; - max-width: 320px; - font-size: 12px; - color: var(--el-text-color-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .progress { font-size: 13px; font-variant-numeric: tabular-nums; diff --git a/frontend/src/views/papers/PaperDetailView.vue b/frontend/src/views/papers/PaperDetailView.vue index 0b8ae2b..fa76a4c 100644 --- a/frontend/src/views/papers/PaperDetailView.vue +++ b/frontend/src/views/papers/PaperDetailView.vue @@ -222,8 +222,6 @@ onMounted(load) {{ word }} - -

{{ paper.abstract }}

@@ -384,13 +382,6 @@ onMounted(load) margin-top: 8px; } -.head-abstract { - margin: 10px 0 0; - font-size: 13px; - line-height: 1.8; - color: var(--el-text-color-regular); -} - .head-actions { display: flex; align-items: center;