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