"""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 abstract: str | 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 abstract: str | 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 with everything the table does not need.""" abstract: str | None 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", ]