Files
paper-doc/backend/alembic/versions/c41d7b09e5af_create_paper_authoring_schema.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

166 lines
6.9 KiB
Python

"""create paper authoring schema
Adds the three tables behind the 论文 feature — writing a paper against a
template:
``paper`` the document: title, author, status, keywords,
target journal, and the template it is written
against.
``paper_sentence`` one row per sentence, addressed by the *position*
(``paper_template_filed_sort``) of the paragraph it
belongs to rather than by a foreign key to a
``template_field`` row, plus its own ``sort``
inside that paragraph.
``paper_sentence_reference`` citations of one sentence, many per sentence.
Why the paragraph is addressed by position
------------------------------------------
Two templates have no rows in common; what they can share is a position. A
sentence that remembers "I sit at position 7" therefore survives a template
swap: it moves to whatever the new template puts at position 7, and a position
the new template does not have is still rendered, in order, under an unset
heading. Nothing is deleted by a swap and nothing is lost — see
``app.crud.paper.build_document``.
Notes on the foreign keys, as elsewhere in this schema: TiDB parses
``FOREIGN KEY`` for compatibility and then ignores it. They are declared to
document the relationships and the integrity is enforced in the application
layer. ``paper_sentence_reference.reference_id`` deliberately has **no**
foreign key: the reference library table does not exist yet.
Revision ID: c41d7b09e5af
Revises: 5030e3939a26
Create Date: 2026-09-18
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c41d7b09e5af"
down_revision: str | None = "5030e3939a26"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
#: ``CURRENT_TIMESTAMP`` rather than ``now()``: the spelling MySQL and TiDB
#: both accept as a DATETIME column default without expression parentheses.
_NOW = sa.text("CURRENT_TIMESTAMP")
def upgrade() -> None:
op.create_table(
"paper",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
# Nullable: a paper may exist before it has chosen a template.
sa.Column("template_id", sa.Integer(), nullable=True),
sa.Column("abstract", sa.Text(), nullable=True),
sa.Column("author", sa.String(length=255), nullable=True),
# 草稿 / 撰写中 / 已完成, stored as the short token.
sa.Column(
"status",
sa.String(length=16),
server_default=sa.text("'draft'"),
nullable=False,
),
# 关键词 as one comma-separated string.
sa.Column("keywords", sa.String(length=255), nullable=True),
sa.Column("target_journal", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=_NOW, nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=_NOW, nullable=False),
sa.ForeignKeyConstraint(
["template_id"], ["paper_template.id"], ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_paper_template_id", "paper", ["template_id"], unique=False)
op.create_table(
"paper_sentence",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("paper_id", sa.Integer(), nullable=False),
# Provenance: the template this sentence was written against. Cleared,
# not cascaded, when that template is deleted.
sa.Column("template_id", sa.Integer(), nullable=True),
# The paragraph, as the template placement's `sort` — not a field id.
sa.Column("paper_template_filed_sort", sa.Integer(), nullable=False),
# This sentence's position inside that paragraph.
sa.Column("sort", sa.Integer(), nullable=False),
# TEXT: a "sentence" is whatever the writer treats as one line, and a
# line can be long. No server default — MySQL and TiDB cannot default a
# TEXT column, and the ORM always supplies a value.
sa.Column("content", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=_NOW, nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=_NOW, nullable=False),
sa.ForeignKeyConstraint(["paper_id"], ["paper.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(
["template_id"], ["paper_template.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
# One index for the document read: every sentence of a paper, in paragraph
# then sentence order.
op.create_index(
"ix_paper_sentence_paper_paragraph_sort",
"paper_sentence",
["paper_id", "paper_template_filed_sort", "sort"],
unique=False,
)
op.create_index(
"ix_paper_sentence_template_id", "paper_sentence", ["template_id"], unique=False
)
op.create_table(
"paper_sentence_reference",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("sentence_id", sa.Integer(), nullable=False),
# No foreign key: the reference library does not exist yet. Plain
# integer, nullable, so a citation can be written before it is linked.
sa.Column("reference_id", sa.Integer(), nullable=True),
# 引用内容 — required. A citation that does not say what it quotes is
# rejected by the API.
sa.Column("quote", sa.Text(), nullable=False),
sa.Column("sort", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["sentence_id"], ["paper_sentence.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_paper_sentence_reference_sentence_sort",
"paper_sentence_reference",
["sentence_id", "sort"],
unique=False,
)
op.create_index(
"ix_paper_sentence_reference_reference_id",
"paper_sentence_reference",
["reference_id"],
unique=False,
)
def downgrade() -> None:
# Reverse dependency order throughout.
op.drop_index(
"ix_paper_sentence_reference_reference_id",
table_name="paper_sentence_reference",
)
op.drop_index(
"ix_paper_sentence_reference_sentence_sort",
table_name="paper_sentence_reference",
)
op.drop_table("paper_sentence_reference")
op.drop_index("ix_paper_sentence_template_id", table_name="paper_sentence")
op.drop_index(
"ix_paper_sentence_paper_paragraph_sort", table_name="paper_sentence"
)
op.drop_table("paper_sentence")
op.drop_index("ix_paper_template_id", table_name="paper")
op.drop_table("paper")