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:
@@ -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}>"
|
||||
)
|
||||
Reference in New Issue
Block a user