06d7e922bd
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.
106 lines
4.4 KiB
Python
106 lines
4.4 KiB
Python
"""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}>"
|
|
)
|