Files
paper-doc/backend/app/models/paper_sentence.py
T
govin 06d7e922bd 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.
2026-09-18 17:29:12 +08:00

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}>"
)