docs: describe the paper authoring model, API, and views

Records the two decisions the paper feature rests on: a sentence is addressed
by the template placement's `sort` rather than by a row id, and citations are
a table rather than a column. Both explain behaviour that otherwise looks
arbitrary — why an empty paper already has its structure, why a template switch
destroys nothing, and why content with no matching heading is still rendered.

Also documents the new endpoints, the /papers/:id route, the paper view's three
jobs, and the smoke test.
This commit is contained in:
2026-09-18 17:29:19 +08:00
parent 4d749b3592
commit 2d113f9f6b
+141 -15
View File
@@ -9,6 +9,12 @@ The first feature to land is the **template configuration**: a reusable library
of section fields plus named templates built from them, so that a paper is of section fields plus named templates built from them, so that a paper is
written by filling in a known structure instead of deciding one. written by filling in a known structure instead of deciding one.
The second is **paper authoring**: a paper is a document written against a
template, sentence by sentence, paragraph by paragraph. The structure is never
copied into the paper — it is read from the template on every render — so
switching a paper's template re-shapes the whole document in one write without
touching a single sentence.
## Tech Stack ## Tech Stack
**Backend** **Backend**
@@ -51,7 +57,7 @@ Browser ──HTTP/JSON──▶ FastAPI ──SQLAlchemy──▶ TiDB (k3s)
## Domain Model ## Domain Model
Three tables, and one rule that everything else follows from. Six tables, and one rule that the last three follow from.
``` ```
section_field the reusable heading library section_field the reusable heading library
@@ -62,6 +68,16 @@ paper_template a named outline
template_field the join, and the only home of display order template_field the join, and the only home of display order
id, template_id → paper_template, field_id → section_field, sort id, template_id → paper_template, field_id → section_field, sort
paper the document
id, title, template_id, abstract, author, status, keywords,
target_journal
paper_sentence one sentence, at one position, in one paper
id, paper_id → paper, template_id, paper_template_filed_sort, sort, content
paper_sentence_reference the citations of one sentence
id, sentence_id → paper_sentence, reference_id, quote, sort
``` ```
### The field library is flat, not a tree ### The field library is flat, not a tree
@@ -103,17 +119,64 @@ typography. Renaming or restyling a field therefore updates every template that
places it, which is what makes "fix the template and the section names follow" places it, which is what makes "fix the template and the section names follow"
work. work.
### A sentence is addressed by position, not by field id
`paper_sentence.paper_template_filed_sort` holds the `sort` of the template
placement the sentence belongs to — **not** `template_field.id`, and not
`section_field.id`. Together with `sort`, the sentence's own position inside
that paragraph, it is everything needed to place a line of text.
The indirection is the feature. Two templates have no rows in common, but they
can share a position, so a sentence that remembers "I sit at position 7" lands
on whatever the new template puts at position 7 when the paper is switched. The
alternative — pointing at a placement row — would make every sentence belong to
one template and turn a switch into a re-mapping of every line.
Three behaviours fall out of that one choice:
- **The structure exists before the content.** A paragraph is any position the
template defines *or* any position a sentence occupies, so an unwritten
paragraph still renders, empty, and a paper created a second ago already has
its full shape.
- **A switch destroys nothing.** Positions the new template does not define are
still rendered, in their place in the order, under the heading 未设定. Switch
back and the old headings return, because the sentences never moved.
- **Ties and gaps are fine.** Sentences may share a `sort` (broken by `id`) and
may be numbered `10, 20, 30` to leave room, exactly as `template_field.sort`
allows.
The name `paper_template_filed_sort` keeps the spelling the feature was
specified with; it is the template field's `sort` (the `sort` column of
`template_field`). `paper_sentence.template_id` is the template the sentence was
written against. It is kept in step with the paper's current template and is
provenance rather than a lookup key: rendering never filters on it, which is
precisely why content survives a switch.
### Citations are a table, not a column
One sentence may quote several references, so
`paper_sentence_reference` holds one row per citation. `quote` — 引用内容 — is
required and must not be blank: a citation that does not say what it quotes is
rejected with `422` rather than stored as a half-record. `reference_id` is a
plain nullable integer with **no** foreign key, because the reference library
does not exist yet; a citation may therefore be written now and linked later.
### TiDB does not enforce foreign keys ### TiDB does not enforce foreign keys
TiDB parses `FOREIGN KEY` for compatibility and then ignores it. The constraints TiDB parses `FOREIGN KEY` for compatibility and then ignores it. The constraints
are declared to document the relationships, and the integrity they would provide are declared to document the relationships, and the integrity they would provide
is enforced in the application layer instead: is enforced in the application layer instead:
- deleting a field still placed in a template is refused with `409`, naming the - deleting a field still placed in a template is refused with `409`, naming the
field and how many templates use it; field and how many templates use it;
- creating a template that references a missing field is refused with `400`; - deleting a template that a paper is written against is refused with `409`,
- `PaperTemplate.items` uses `cascade="all, delete-orphan"`, so deleting a naming the template and how many papers use it — the template is that paper's
template removes its rows from the join table. structure, so removing it would empty the paper rather than tidy up;
- creating a template that references a missing field is refused with `400`, and
creating or patching a paper that references a missing template likewise;
- `PaperTemplate.items`, `Paper.sentences` and `PaperSentence.citations` all use
`cascade="all, delete-orphan"`, so deleting a row removes its dependents.
## API ## API
@@ -130,6 +193,15 @@ All routes are mounted under `/api`. Interactive docs at `/docs`.
| `POST` | `/templates` | name + abstract + ordered `fields` | | `POST` | `/templates` | name + abstract + ordered `fields` |
| `GET` `PATCH` `DELETE` | `/templates/{id}` | `PATCH` with `fields` replaces the selection | | `GET` `PATCH` `DELETE` | `/templates/{id}` | `PATCH` with `fields` replaces the selection |
| `POST` | `/templates/batch-delete` | body `{ "ids": [...] }` | | `POST` | `/templates/batch-delete` | body `{ "ids": [...] }` |
| `GET` | `/papers` | `keyword` matches title/author/keywords, plus `status`, `template_id` |
| `POST` | `/papers` | title + template + author/status/keywords/journal; no sentence rows are created |
| `GET` `PATCH` `DELETE` | `/papers/{id}` | `PATCH` with `template_id` **is** the template switch |
| `POST` | `/papers/batch-delete` | body `{ "ids": [...] }` |
| `GET` | `/papers/{id}/document` | the whole paper: paragraphs in order, citations, warnings |
| `GET` | `/papers/{id}/paragraphs/{sort}` | one paragraph, assembled as the document renders it |
| `PUT` | `/papers/{id}/paragraphs/{sort}` | full replacement; optional `target_sort` moves it |
| `POST` | `/papers/{id}/sentences` | append one sentence |
| `PATCH` `DELETE` | `/papers/{id}/sentences/{id}` | edit or remove one sentence |
Conventions worth knowing: Conventions worth knowing:
@@ -139,6 +211,17 @@ Conventions worth knowing:
it before using it in a CSS rule. it before using it in a CSS rule.
- List endpoints return `{ items, total, page, page_size, pages }`. - List endpoints return `{ items, total, page, page_size, pages }`.
- A template read returns `fields` already ordered by `sort`; clients never sort. - A template read returns `fields` already ordered by `sort`; clients never sort.
- A paper document returns its `paragraphs` in ascending position order,
already carrying their sentences and citation numbering. The client sorts
nothing and merges nothing: two implementations of the same ordering rule
would eventually disagree.
- Sentence `content` is folded to a single line (leading, trailing and inner
whitespace runs collapse to one space), so an all-whitespace sentence becomes
the empty string — legal, and rendered as a blank line. A citation with a
blank `quote` is refused with `422`.
- `keywords` is stored as a canonical ``, ``-joined string; ``、`;` and ``
are accepted on write and de-duplicated.
- Paper `status` is one of `draft`, `writing`, `done`.
## Frontend ## Frontend
@@ -148,17 +231,26 @@ Conventions worth knowing:
┌───────────────────┬──────────────────────────────────────┐ ┌───────────────────┬──────────────────────────────────────┐
│ [mark] paper-doc │ 论文 模板 [mk] │ header │ [mark] paper-doc │ 论文 模板 [mk] │ header
├───────────────────┼──────────────────────────────────────┤ ├───────────────────┼──────────────────────────────────────┤
模板配置 │ │ second-level 论文 │ │ second-level
模板列表 │ <RouterView> │ menu, left 论文列表 │ <RouterView> │ menu, left
字段管理 │ │ 新建论文 │ │
│ ── 我的论文 ── │ │
│ ● 论文标题 A │ │
│ ● 论文标题 B │ │
└───────────────────┴──────────────────────────────────────┘ └───────────────────┴──────────────────────────────────────┘
<- 208px -> <- 208px ->
``` ```
The mark appears at both ends of the header. The second-level menu sits on the The mark appears at both ends of the header. The second-level menu sits on the
**left** and is driven entirely by `route.meta.section`, so a route declares **left** and is driven by `route.meta.section`, so a route declares which menu
which menu it belongs to and deep links render correctly on first paint. Routes it belongs to and deep links render correctly on first paint. Routes without a
without a `section` (the welcome page) show no menu. `section` (the welcome page) show no menu.
The 论文 menu is the one that is not a static list: it carries the papers
themselves, read from the `papers` store, so a paper can be opened straight from
the rail. Every page that creates, renames or deletes a paper reloads that
store, which is what keeps the rail and the table showing the same thing. A
filter box appears once there are more than six papers.
Two things about this layout are deliberate and easy to break by accident: Two things about this layout are deliberate and easy to break by accident:
@@ -188,18 +280,38 @@ stylesheet.
| Path | View | Section | | Path | View | Section |
|---|---|---| |---|---|---|
| `/` | welcome | — | | `/` | welcome | — |
| `/papers` | 论文 (content TBD) | `papers` | | `/papers` | paper table + CRUD (`?new=1` opens the create dialog) | `papers` |
| `/papers/:id` | one paper, read as a document | `papers` |
| `/templates` | redirects to `/templates/list` | — | | `/templates` | redirects to `/templates/list` | — |
| `/templates/list` | template table + CRUD | `templates` | | `/templates/list` | template table + CRUD | `templates` |
| `/templates/fields` | field library + CRUD | `templates` | | `/templates/fields` | field library + CRUD | `templates` |
### The paper view
`PapersView` is the library; `papers/PaperDetailView` is the writing surface.
The detail view renders the server's document verbatim and adds three things it
alone knows how to do:
- **an edit button beside every paragraph**, written or not — content only ever
enters a paper through the paragraph editor, so it may never be the thing that
is missing;
- **numbered citation markers** in the text, matching the 参考文献 list, which
is numbered in reading order rather than per paragraph;
- **a 切换模板 dialog that previews the impact** — how much content lands under
a new heading, how much keeps its place under 未设定, and how many empty
paragraphs the new template adds — because a switch re-shapes the whole
document in one write.
Changing a paper's template from the metadata form is deliberately disabled: a
silent re-shape of the document is exactly what the preview exists to prevent.
## Directory Structure ## Directory Structure
``` ```
paper-doc/ paper-doc/
├── backend/ # FastAPI application ├── backend/ # FastAPI application
│ ├── app/ │ ├── app/
│ │ ├── api/routes/ # route handlers (health, section_fields, templates) │ │ ├── api/routes/ # route handlers (health, papers, section_fields, templates)
│ │ ├── core/ # settings and configuration │ │ ├── core/ # settings and configuration
│ │ ├── crud/ # data-access helpers │ │ ├── crud/ # data-access helpers
│ │ ├── db/ # engine, session, declarative base │ │ ├── db/ # engine, session, declarative base
@@ -207,18 +319,19 @@ paper-doc/
│ │ └── schemas/ # Pydantic request/response models │ │ └── schemas/ # Pydantic request/response models
│ ├── alembic/ # migration environment and revisions │ ├── alembic/ # migration environment and revisions
│ ├── scripts/seed.py # idempotent seed for the field library + templates │ ├── scripts/seed.py # idempotent seed for the field library + templates
│ ├── scripts/smoke_papers.py # end-to-end check of the writing loop
│ ├── alembic.ini │ ├── alembic.ini
│ ├── requirements.txt │ ├── requirements.txt
│ └── .env.example │ └── .env.example
├── frontend/ # Vue 3 SPA ├── frontend/ # Vue 3 SPA
│ ├── src/ │ ├── src/
│ │ ├── api/ # axios instance and endpoint modules │ │ ├── api/ # axios instance and endpoint modules
│ │ ├── components/ # shell, field and template components │ │ ├── components/ # shell, field, template and paper components
│ │ ├── router/ # vue-router configuration │ │ ├── router/ # vue-router configuration
│ │ ├── stores/ # pinia stores (persisted) │ │ ├── stores/ # pinia stores (UI prefs persisted, the paper list not)
│ │ ├── styles/ # global reset and the shell viewport contract │ │ ├── styles/ # global reset and the shell viewport contract
│ │ ├── utils/ # formatting helpers │ │ ├── utils/ # formatting helpers
│ │ └── views/ # route-level components │ │ └── views/ # route-level components (papers/, templates/)
│ ├── package.json │ ├── package.json
│ └── vite.config.ts │ └── vite.config.ts
└── docs/ # project documentation └── docs/ # project documentation
@@ -278,3 +391,16 @@ cd backend
.venv/bin/python scripts/seed.py # add anything missing .venv/bin/python scripts/seed.py # add anything missing
.venv/bin/python scripts/seed.py --reset # empty the tables first .venv/bin/python scripts/seed.py --reset # empty the tables first
``` ```
**Checking the paper feature end to end**
`scripts/smoke_papers.py` walks the whole writing loop against a running API —
create a paper on a template, verify the empty structure, fill a paragraph with
sentences and citations, switch the template, check that unmatched positions
survive under an unset heading, move a paragraph, and delete it all again. It
exits non-zero on the first failed expectation and cleans up after itself:
```bash
cd backend
.venv/bin/python scripts/smoke_papers.py
```