Files
paper-doc/backend/app/schemas/paper.py
T
govin 45926af966 refactor: make the abstract a paragraph instead of a paper field
A template's abstract paragraph (`0 Abstract`) *is* the paper's abstract: it has
a position in the document, the heading and typography the template gives it,
and the same sentence-by-sentence editing as everything else. `paper.abstract`
was a second home for that text — one the document never reads, so a paper
could show two different abstracts and the column could drift from the body.

The column is gone, from the table, the model, the schemas, the API payloads,
the paper form, the list subtitle and the paper page. Nothing else changed.

The text already written into it is not gone. Revision a83f5c21d7b6 writes each
stored abstract into the paper's body first — one sentence per 。 at the
paragraph carrying the abstract heading, appended after anything already there
rather than replacing it. A template without such a heading keeps the text too,
one position above its first paragraph, where the document renders it under
未设定. Verified against the one paper that had an abstract: 450 characters in,
450 out, identical including `|J| ≤ α · I⁻ᵝ`, split into six sentences in the
`0 Abstract` paragraph, still with its own edit button.

The smoke test no longer assumes an empty database: it records the paper total
before it starts and compares against that, so it can run on a real one.
2026-09-18 18:55:10 +08:00

381 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Request/response schemas for papers (论文).
Three shapes of payload live here:
* the **paper** itself — title, author, status, keywords, target journal and
the template it is written against;
* **paragraph** payloads — the writer's unit of work. A paragraph is edited by
reading every sentence of it and writing the whole set back, so the update
schema is a full replacement;
* the **document** — what the reader sees: the template's paragraphs in order,
each carrying the sentences stored at its position, plus the flat citation
list for the 参考文献 block.
Text normalisation is deliberately strict, because the two sides of the wire
have to agree on what counts as empty:
* a *sentence* is one line — leading/trailing whitespace goes, and any run of
whitespace inside (including a pasted newline or an ideographic space) folds
to a single space. An all-whitespace sentence becomes the empty string, which
is legal and renders as a blank line;
* a *title* must not be blank once trimmed;
* a *citation* must quote something — non-blank ``quote`` — or it is rejected.
"""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
#: Writing states, mirroring :data:`app.models.paper.PAPER_STATUSES`. Kept as a
#: literal so the generated OpenAPI document describes the enum for clients.
PaperStatus = Literal["draft", "writing", "done"]
#: Characters that separate keywords in the stored string. Half-width and
#: full-width commas, the ideographic comma, and semicolons — a user types
#: whichever one their input method offers.
KEYWORD_SEPARATORS = ",,、;"
def fold_whitespace(value: str) -> str:
"""Collapse every whitespace run in ``value`` to one space and trim it.
``str.split`` with no argument splits on Unicode whitespace, which covers
the ideographic space (U+3000) a Chinese input method produces, so a pasted
line cannot smuggle an invisible full-width blank into a "trimmed" field.
"""
return " ".join(value.split())
def normalize_keywords(value: str | None) -> str | None:
"""Canonicalise the keyword string: trimmed, de-duplicated, ``,``-joined.
Returns ``None`` for a value that carries no keywords, so "cleared" and
"never set" are stored the same way.
"""
if value is None:
return None
parts: list[str] = []
for chunk in value.replace("", ";").replace("", ",").replace("", ",").split(";"):
for item in chunk.split(","):
word = fold_whitespace(item)
if word and word not in parts:
parts.append(word)
return ", ".join(parts) or None
class CitationInput(BaseModel):
"""One citation of one sentence.
``quote`` is required: the API refuses a citation that does not say what it
quotes. ``reference_id`` is optional, because the reference library is
still to come — a citation may be written now and linked later.
"""
reference_id: int | None = Field(default=None, ge=1)
quote: str = Field(default="", max_length=4000)
@field_validator("quote")
@classmethod
def _require_quote(cls, value: str) -> str:
text = value.strip()
if not text:
raise ValueError("引用的内容不能为空")
return text
class SentenceInput(BaseModel):
"""One sentence of a paragraph, as the editor sends it.
``sort`` is optional: omit it and the server numbers the sentences from 1
in the order they arrive, which is what the editor does by default. Send it
to keep a deliberate numbering (10, 20, 30 …) or a tie.
"""
sort: int | None = Field(default=None, ge=-1_000_000, le=1_000_000)
content: str = ""
citations: list[CitationInput] = Field(default_factory=list)
@field_validator("content")
@classmethod
def _normalise_content(cls, value: str) -> str:
return fold_whitespace(value)
class ParagraphUpdate(BaseModel):
"""Payload for ``PUT /papers/{id}/paragraphs/{sort}``.
A full replacement of the paragraph's sentences. ``target_sort`` moves the
whole paragraph: the sentences are written into another paragraph's
position, appended after whatever that paragraph already holds. That is the
manual form of what a template switch does by itself.
"""
sentences: list[SentenceInput] = Field(default_factory=list)
target_sort: int | None = Field(default=None, ge=-1_000_000, le=1_000_000)
class SentenceCreate(SentenceInput):
"""Payload for ``POST /papers/{id}/sentences`` — one new sentence."""
paper_template_filed_sort: int = Field(ge=-1_000_000, le=1_000_000)
class SentenceUpdate(BaseModel):
"""Payload for ``PATCH /papers/{id}/sentences/{sentence_id}``.
Every part is optional. ``citations`` is a full replacement when present.
"""
sort: int | None = Field(default=None, ge=-1_000_000, le=1_000_000)
paper_template_filed_sort: int | None = Field(
default=None, ge=-1_000_000, le=1_000_000
)
content: str | None = None
citations: list[CitationInput] | None = None
@field_validator("content")
@classmethod
def _normalise_content(cls, value: str | None) -> str | None:
return None if value is None else fold_whitespace(value)
# --- reads ------------------------------------------------------------------
class CitationRead(BaseModel):
"""A stored citation."""
model_config = ConfigDict(from_attributes=True)
id: int
reference_id: int | None
quote: str
sort: int
class SentenceRead(BaseModel):
"""A stored sentence with its citations."""
model_config = ConfigDict(from_attributes=True)
id: int
paper_id: int
template_id: int | None
paper_template_filed_sort: int
sort: int
content: str
citations: list[CitationRead]
created_at: datetime
updated_at: datetime
class ParagraphRead(BaseModel):
"""One paragraph of a paper: its heading, and the sentences under it.
``name`` is ``None`` when the paper's template has no placement at this
position — the case that makes a template switch non-destructive. The
heading is then rendered as *未设定* while the sentences keep their place in
the document; ``matched`` says which of the two happened.
"""
paper_template_filed_sort: int
#: The placement this paragraph resolved to, when there was one.
template_field_id: int | None = None
field_id: int | None = None
#: Heading text as written in the template — the numbering is part of it.
name: str | None = None
level: int = 1
font_size: float | None = None
font_color: str | None = None
#: ``True`` when the template has a placement at this position.
matched: bool = False
sentences: list[SentenceRead]
class PaperCitationRead(BaseModel):
"""One citation as it appears in the paper's 参考文献 list.
``index`` is the number shown in the text as ``[index]``; it counts
citations in reading order across the whole document, so the list and the
markers cannot disagree.
"""
index: int
id: int
reference_id: int | None
quote: str
sentence_id: int
sentence_content: str
paper_template_filed_sort: int
paragraph_name: str | None = None
class PaperBase(BaseModel):
"""Shared body of the paper create payload."""
title: str = Field(min_length=1, max_length=255)
template_id: int | None = None
author: str | None = Field(default=None, max_length=255)
status: PaperStatus = "draft"
keywords: str | None = Field(default=None, max_length=255)
target_journal: str | None = Field(default=None, max_length=255)
@field_validator("title")
@classmethod
def _require_title(cls, value: str) -> str:
text = value.strip()
if not text:
raise ValueError("论文标题不能为空")
return text
@field_validator("author", "target_journal")
@classmethod
def _blank_to_none(cls, value: str | None) -> str | None:
"""Treat a whitespace-only author or journal as "not set"."""
if value is None:
return None
return value.strip() or None
@field_validator("keywords")
@classmethod
def _normalise_keywords(cls, value: str | None) -> str | None:
return normalize_keywords(value)
class PaperCreate(PaperBase):
"""Payload for ``POST /papers``.
Creation is metadata only: the structure comes from the chosen template,
read live at render time, so there is nothing to copy and no empty rows to
materialise. The paper appears with its whole outline and no content.
"""
class PaperUpdate(BaseModel):
"""Payload for ``PATCH /papers/{id}`` — every part optional.
Changing ``template_id`` is the template switch: it re-shapes the document
in one write. Sentence rows are untouched, which is why switching back
restores the previous layout exactly.
"""
title: str | None = Field(default=None, min_length=1, max_length=255)
template_id: int | None = None
author: str | None = Field(default=None, max_length=255)
status: PaperStatus | None = None
keywords: str | None = Field(default=None, max_length=255)
target_journal: str | None = Field(default=None, max_length=255)
@field_validator("title")
@classmethod
def _require_title(cls, value: str | None) -> str | None:
if value is None:
return None
text = value.strip()
if not text:
raise ValueError("论文标题不能为空")
return text
@field_validator("author", "target_journal")
@classmethod
def _blank_to_none(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip() or None
@field_validator("keywords")
@classmethod
def _normalise_keywords(cls, value: str | None) -> str | None:
return normalize_keywords(value)
class PaperListItem(BaseModel):
"""A paper as it appears in the table, without its content."""
id: int
title: str
template_id: int | None
#: The template's name, or ``None`` when no template is chosen.
template_name: str | None
author: str | None
status: PaperStatus
keywords: str | None
target_journal: str | None
#: How many sentences have been written.
sentence_count: int
#: How many distinct paragraphs those sentences occupy.
paragraph_count: int
#: How many paragraphs the current template defines — the denominator of
#: the progress read-out. 0 when no template is chosen.
template_paragraph_count: int
created_at: datetime
updated_at: datetime
class PaperRead(PaperListItem):
"""A paper as its own page needs it.
Kept separate from :class:`PaperListItem` even though it currently adds no
field, because the two are different contracts: the table's row and the
page's subject. The paper's abstract is not here — it is a paragraph of the
document (see ``app.crud.paper.build_document``), not a property of the
paper.
"""
class PaperDocumentRead(BaseModel):
"""The whole paper as it reads: structure, content, citations, warnings.
Assembled by :func:`app.crud.paper.build_document` in a single pass, so a
client renders from one response and never has to stitch arrays together.
"""
paper: PaperRead
#: Paragraphs in ascending position order. Every position that either the
#: template or a sentence mentions appears exactly once, so an empty
#: paragraph is still a paragraph — the structure survives with no content.
paragraphs: list[ParagraphRead]
#: Citations in reading order, numbered to match the in-text markers.
citations: list[PaperCitationRead]
#: Anything the reader should be told about the document as a whole, e.g.
#: a template that places two fields at the same position.
warnings: list[str] = Field(default_factory=list)
class ParagraphListRead(BaseModel):
"""Response of a paragraph read: the paragraph plus the paper it belongs to.
The editor needs to know which paper it is editing and where else it could
move the paragraph to, and both are already loaded by the document query.
"""
paper_id: int
paper_title: str
template_id: int | None
paragraph: ParagraphRead
__all__ = [
"CitationInput",
"CitationRead",
"KEYWORD_SEPARATORS",
"PaperBase",
"PaperCitationRead",
"PaperCreate",
"PaperDocumentRead",
"PaperListItem",
"PaperRead",
"PaperStatus",
"PaperUpdate",
"ParagraphListRead",
"ParagraphRead",
"ParagraphUpdate",
"SentenceCreate",
"SentenceRead",
"SentenceUpdate",
"fold_whitespace",
"normalize_keywords",
]