backend: add the paper authoring schema, API, and smoke test

A paper is a document written against a template. Its structure is never
copied into it: `paper` points at a template and the outline is read live on
every render, so switching `template_id` re-shapes the whole document in one
write.

Sentences are addressed by *position* rather than by a template row id:
`paper_sentence.paper_template_filed_sort` holds the sort of the placement the
sentence belongs to, and `sort` holds its place inside that paragraph. That
indirection is what makes a template switch non-destructive — a sentence that
remembers "position 7" lands on whatever the new template puts at position 7 —
and it is why a paragraph is any position either the template or the content
mentions: the structure survives with no content, and content whose position
the template does not define is still rendered, in order, under 未设定.

Citations are a table rather than a column, since one sentence may quote
several references. `quote` is required — a citation that does not say what it
quotes is refused with 422 — while `reference_id` is a plain nullable integer
with no foreign key, because the reference library does not exist yet.

Deleting a template a paper is written against is refused with 409 and a count,
matching how the field library refuses to drop a field still in use.

scripts/smoke_papers.py walks the whole loop — create, empty structure, write a
paragraph with citations, switch templates, keep unmatched positions, move a
paragraph, delete — in 40 checks, and cleans up after itself.
This commit is contained in:
2026-09-18 17:29:12 +08:00
parent 0d0aa20be2
commit 06d7e922bd
13 changed files with 2173 additions and 5 deletions
+20
View File
@@ -3,14 +3,34 @@
Importing this package registers every model on ``Base.metadata``, which is
what ``alembic revision --autogenerate`` inspects. A new model therefore has to
be added to the imports below, not only to its own module.
The import order here is for readability only: relationships are declared by
name and resolved through the SQLAlchemy registry after every module has been
imported, so a cycle between two model modules is not a problem.
"""
from app.models.mixins import TimestampMixin
from app.models.paper import (
PAPER_STATUSES,
STATUS_DONE,
STATUS_DRAFT,
STATUS_WRITING,
Paper,
)
from app.models.paper_sentence import PaperSentence
from app.models.paper_sentence_reference import PaperSentenceReference
from app.models.paper_template import PaperTemplate
from app.models.section_field import SectionField
from app.models.template_field import TemplateField
__all__ = [
"PAPER_STATUSES",
"STATUS_DONE",
"STATUS_DRAFT",
"STATUS_WRITING",
"Paper",
"PaperSentence",
"PaperSentenceReference",
"PaperTemplate",
"SectionField",
"TemplateField",
+116
View File
@@ -0,0 +1,116 @@
"""Papers (论文表) — the document this tool exists to produce.
A paper is a bag of metadata plus two things that are deliberately kept apart:
* its **structure**, which is not stored here at all. Every render reads the
:class:`~app.models.paper_template.PaperTemplate` the paper points at, live.
Nothing is copied, so switching ``template_id`` re-shapes the whole document
in one write.
* its **content**, which lives in
:class:`~app.models.paper_sentence.PaperSentence` — one row per sentence,
addressed by the paragraph's ``sort`` inside that template rather than by a
foreign key to a ``template_field`` row.
That second choice is what makes the promised workflow work. Because a sentence
remembers "I belong at position 7", not "I belong to placement #42", swapping
the template moves every sentence to whatever the new template has at position
7. Sentences whose position the new template does not have are not lost and not
hidden: they are still rendered in position order, under an *unset* heading.
See :func:`app.crud.paper.build_document`.
``status`` is one of :data:`PAPER_STATUSES`. It is stored as the short token
(``draft``), not as the Chinese label, so the wording in the UI can change
without a migration.
"""
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.mixins import TimestampMixin
if TYPE_CHECKING: # pragma: no cover - typing only
from app.models.paper_sentence import PaperSentence
from app.models.paper_template import PaperTemplate
#: Writing state of a paper: 草稿 / 撰写中 / 已完成.
#:
#: Exactly the values a client may send; the API validates against this tuple,
#: so adding a state is a one-line change here plus one label in the UI.
PAPER_STATUSES: tuple[str, ...] = ("draft", "writing", "done")
STATUS_DRAFT = "draft"
STATUS_WRITING = "writing"
STATUS_DONE = "done"
class Paper(TimestampMixin, Base):
"""One paper: a working title, who is writing it, and where it is going."""
__tablename__ = "paper"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
#: Working title. Not unique: two papers may legitimately share a title
#: while one is being split off the other.
title: Mapped[str] = mapped_column(String(255), nullable=False)
#: The template this paper is currently written against, or ``None`` for a
#: paper that has not chosen one yet. Nullable only for robustness — the
#: UI asks for a template at creation time — and a paper without one simply
#: renders its sentences under unset headings.
#:
#: ``RESTRICT`` documents the intent (a template in use must not vanish);
#: TiDB does not enforce it, so the API refuses the template delete too.
template_id: Mapped[int | None] = mapped_column(
ForeignKey("paper_template.id", ondelete="RESTRICT"),
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)
#: One of :data:`PAPER_STATUSES`.
status: Mapped[str] = mapped_column(
String(16),
nullable=False,
default=STATUS_DRAFT,
server_default=text("'draft'"),
)
#: 关键词, stored as one comma-separated string. A list would need a table
#: of its own for something that is displayed as a row of tags and searched
#: as text; the split/join happens at the edges.
keywords: Mapped[str | None] = mapped_column(String(255), nullable=True)
#: 投稿目标期刊.
target_journal: Mapped[str | None] = mapped_column(String(255), nullable=True)
#: 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["PaperTemplate | None"] = relationship(lazy="joined")
#: The paper's sentences, in document order.
#:
#: ``delete-orphan`` is doing real work: TiDB parses but does not enforce
#: ``ON DELETE CASCADE``, so removing a deleted paper's sentences is the
#: ORM's job. The ordering mirrors the render order exactly — paragraph
#: position first, then position inside the paragraph, then insertion.
sentences: Mapped[list["PaperSentence"]] = relationship(
back_populates="paper",
cascade="all, delete-orphan",
order_by=(
"PaperSentence.paper_template_filed_sort, "
"PaperSentence.sort, "
"PaperSentence.id"
),
lazy="selectin",
)
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<Paper id={self.id} title={self.title!r} status={self.status}>"
+105
View File
@@ -0,0 +1,105 @@
"""Paper sentences (论文句子表) — the actual writing.
One row is one sentence of one paper. The two columns that place it are the
whole design:
``paper_template_filed_sort``
The ``sort`` of the template placement this sentence belongs to — *not* a
foreign key to ``template_field``. It is deliberately the position rather
than the row id, because the position is the only thing two templates can
meaningfully have in common. Swap the paper's template and this sentence
lands on whatever the new template puts at that position, with no per
sentence editing. (The name keeps the spelling the feature was specified
with; it reads ``paper_template_field_sort``.)
``sort``
Where this sentence sits *inside* that paragraph. A paragraph is reassembled
by reading its rows in ascending ``sort``, which is why the column is
required and why the API never leaves it to chance. Values are sparse-
friendly — 10, 20, 30 leaves room to insert — and ties are legal, broken by
``id`` so the order is always total and stable.
``template_id`` records the template the sentence was *written against*. It is
provenance, not a lookup key: rendering never filters on it, which is exactly
why sentences survive a template switch. It is nullable because deleting an old
template clears it rather than leaving a dangling reference behind.
Position in the paper is therefore not stored anywhere as a whole; the document
is assembled on read from the paper's template plus these rows. See
:func:`app.crud.paper.build_document`.
"""
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.mixins import TimestampMixin
if TYPE_CHECKING: # pragma: no cover - typing only
from app.models.paper import Paper
from app.models.paper_sentence_reference import PaperSentenceReference
from app.models.paper_template import PaperTemplate
class PaperSentence(TimestampMixin, Base):
"""One sentence, at one position, inside one paragraph of one paper."""
__tablename__ = "paper_sentence"
__table_args__ = (
# The document query is "every sentence of this paper, in paragraph then
# sentence order", so one composite index covers filter and sort both.
Index(
"ix_paper_sentence_paper_paragraph_sort",
"paper_id",
"paper_template_filed_sort",
"sort",
),
Index("ix_paper_sentence_template_id", "template_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
paper_id: Mapped[int] = mapped_column(
ForeignKey("paper.id", ondelete="CASCADE"),
nullable=False,
)
#: The template this sentence was written against — provenance only. Reads
#: never filter on it; see the module docstring.
template_id: Mapped[int | None] = mapped_column(
ForeignKey("paper_template.id", ondelete="SET NULL"),
nullable=True,
)
#: Which paragraph, expressed as the template placement's ``sort``.
#: Required: a sentence with no paragraph has nowhere to be rendered.
paper_template_filed_sort: Mapped[int] = mapped_column(Integer, nullable=False)
#: This sentence's position inside that paragraph. Ascending, ties broken
#: by ``id``. Required for the same reason.
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
#: The sentence itself. May be empty: an empty sentence is a legitimate
#: placeholder and renders as a blank line rather than disappearing.
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
paper: Mapped["Paper"] = relationship(back_populates="sentences")
#: References quoted by this sentence, in citation order. Eager-loaded
#: because the document render always needs them, and one query per
#: sentence would be the difference between two queries and two hundred.
citations: Mapped[list["PaperSentenceReference"]] = relationship(
back_populates="sentence",
cascade="all, delete-orphan",
order_by="PaperSentenceReference.sort, PaperSentenceReference.id",
lazy="selectin",
)
def __repr__(self) -> str: # pragma: no cover - debugging aid
return (
f"<PaperSentence id={self.id} paper_id={self.paper_id} "
f"paragraph={self.paper_template_filed_sort} sort={self.sort}>"
)
@@ -0,0 +1,72 @@
"""Citations attached to a sentence (句子引用关联表).
A sentence may quote **several** references, which makes this a real
many-to-many relation rather than a column on ``paper_sentence`` — hence a
table of its own.
Why there is no foreign key to a reference table
------------------------------------------------
References are going to be maintained in their own table later. This one
therefore stores a plain ``reference_id`` integer and declares **no** foreign
key at all: a key to a table that does not exist yet cannot be validated,
declared or migrated. The column is nullable so a citation can be written
before it has been linked to a reference row.
What makes a citation valid
---------------------------
``quote`` — the quoted content — is required and must not be blank. A citation
that points at something without saying what it points at is not usable in a
document, so the API rejects it (``app.schemas.paper.CitationInput``) rather
than storing a dangling half-record. The reverse is fine: a citation may carry
its quoted content with ``reference_id`` still empty, and be linked later.
"""
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
if TYPE_CHECKING: # pragma: no cover - typing only
from app.models.paper_sentence import PaperSentence
class PaperSentenceReference(Base):
"""One citation of one sentence: what is quoted, and optionally by which id."""
__tablename__ = "paper_sentence_reference"
__table_args__ = (
# Reads are "the citations of this sentence, in order".
Index("ix_paper_sentence_reference_sentence_sort", "sentence_id", "sort"),
# The reference side is indexed for the lookup that arrives with the
# reference table: "which sentences quote reference N".
Index("ix_paper_sentence_reference_reference_id", "reference_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
sentence_id: Mapped[int] = mapped_column(
ForeignKey("paper_sentence.id", ondelete="CASCADE"),
nullable=False,
)
#: The id this citation will resolve to once the reference library exists.
#: Deliberately not a foreign key yet — see the module docstring.
reference_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
#: 引用内容 — required, non-blank. The citation's whole payload today.
quote: Mapped[str] = mapped_column(Text, nullable=False)
#: Order among the citations of one sentence. Assigned from the order the
#: client sent them, so ``[1]`` in the text is the first stored row.
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
sentence: Mapped["PaperSentence"] = relationship(back_populates="citations")
def __repr__(self) -> str: # pragma: no cover - debugging aid
return (
f"<PaperSentenceReference sentence_id={self.sentence_id} "
f"reference_id={self.reference_id} sort={self.sort}>"
)