e582198cc8
A paragraph is its sentences concatenated. Nothing was put between them, which is right for Chinese — 「。」 already separates — and wrong for English, where `Adaptive capacity rises.` followed by `Relocation follows.` printed as `...rises.Relocation...`: a sentence boundary the reader cannot see. CJK output hid it, so it would have surfaced as an English bug later rather than now. `sentence_separator` now returns "" or a single space per seam, and the document carries it as `SentenceRead.separator_before`. The rule is about the seam, not the language: a space goes in unless both sides are CJK. Mixed seams take the space. Empty sentences take none. It is derived on every read and never stored, so it cannot drift from the text, and the client prints `separator_before + content` and adds no spacing of its own. No splitting was added anywhere, and none exists: a sentence is one line in the editor and nothing parses it. The single split this project has ever performed was the one-time move of the old abstract column, which cut after 「。」 only — conservative on purpose, since an English abstract is better left in one row than cut at the first `et al.`. Five smoke checks cover the seam (Chinese, English, the assembled paragraph, and that no separator is stored inside the content). 45 checks pass.
386 lines
13 KiB
Python
386 lines
13 KiB
Python
"""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]
|
||
#: Text to print before this sentence when the paragraph is put back
|
||
#: together — ``""`` or a single space, never anything else. Derived on
|
||
#: read by :func:`app.crud.paper.sentence_separator`, never stored, because
|
||
#: a separator that is stored is a separator that can go stale.
|
||
separator_before: str = ""
|
||
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",
|
||
]
|