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:
+141
-15
@@ -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
|
||||
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
|
||||
|
||||
**Backend**
|
||||
@@ -51,7 +57,7 @@ Browser ──HTTP/JSON──▶ FastAPI ──SQLAlchemy──▶ TiDB (k3s)
|
||||
|
||||
## 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
|
||||
@@ -62,6 +68,16 @@ paper_template a named outline
|
||||
|
||||
template_field the join, and the only home of display order
|
||||
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
|
||||
@@ -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"
|
||||
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 parses `FOREIGN KEY` for compatibility and then ignores it. The constraints
|
||||
are declared to document the relationships, and the integrity they would provide
|
||||
is enforced in the application layer instead:
|
||||
|
||||
- deleting a field still placed in a template is refused with `409`, naming the
|
||||
field and how many templates use it;
|
||||
- creating a template that references a missing field is refused with `400`;
|
||||
- `PaperTemplate.items` uses `cascade="all, delete-orphan"`, so deleting a
|
||||
template removes its rows from the join table.
|
||||
- deleting a template that a paper is written against is refused with `409`,
|
||||
naming the template and how many papers use it — the template is that paper's
|
||||
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
|
||||
|
||||
@@ -130,6 +193,15 @@ All routes are mounted under `/api`. Interactive docs at `/docs`.
|
||||
| `POST` | `/templates` | name + abstract + ordered `fields` |
|
||||
| `GET` `PATCH` `DELETE` | `/templates/{id}` | `PATCH` with `fields` replaces the selection |
|
||||
| `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:
|
||||
|
||||
@@ -139,6 +211,17 @@ Conventions worth knowing:
|
||||
it before using it in a CSS rule.
|
||||
- List endpoints return `{ items, total, page, page_size, pages }`.
|
||||
- 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
|
||||
|
||||
@@ -148,17 +231,26 @@ Conventions worth knowing:
|
||||
┌───────────────────┬──────────────────────────────────────┐
|
||||
│ [mark] paper-doc │ 论文 模板 [mk] │ header
|
||||
├───────────────────┼──────────────────────────────────────┤
|
||||
│ 模板配置 │ │ second-level
|
||||
│ 模板列表 │ <RouterView> │ menu, left
|
||||
│ 字段管理 │ │
|
||||
│ 论文 │ │ second-level
|
||||
│ 论文列表 │ <RouterView> │ menu, left
|
||||
│ 新建论文 │ │
|
||||
│ ── 我的论文 ── │ │
|
||||
│ ● 论文标题 A │ │
|
||||
│ ● 论文标题 B │ │
|
||||
└───────────────────┴──────────────────────────────────────┘
|
||||
<- 208px ->
|
||||
```
|
||||
|
||||
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
|
||||
which menu it belongs to and deep links render correctly on first paint. Routes
|
||||
without a `section` (the welcome page) show no menu.
|
||||
**left** and is driven by `route.meta.section`, so a route declares which menu
|
||||
it belongs to and deep links render correctly on first paint. Routes without a
|
||||
`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:
|
||||
|
||||
@@ -188,18 +280,38 @@ stylesheet.
|
||||
| Path | View | Section |
|
||||
|---|---|---|
|
||||
| `/` | 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/list` | template table + 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
|
||||
|
||||
```
|
||||
paper-doc/
|
||||
├── backend/ # FastAPI application
|
||||
│ ├── app/
|
||||
│ │ ├── api/routes/ # route handlers (health, section_fields, templates)
|
||||
│ │ ├── api/routes/ # route handlers (health, papers, section_fields, templates)
|
||||
│ │ ├── core/ # settings and configuration
|
||||
│ │ ├── crud/ # data-access helpers
|
||||
│ │ ├── db/ # engine, session, declarative base
|
||||
@@ -207,18 +319,19 @@ paper-doc/
|
||||
│ │ └── schemas/ # Pydantic request/response models
|
||||
│ ├── alembic/ # migration environment and revisions
|
||||
│ ├── scripts/seed.py # idempotent seed for the field library + templates
|
||||
│ ├── scripts/smoke_papers.py # end-to-end check of the writing loop
|
||||
│ ├── alembic.ini
|
||||
│ ├── requirements.txt
|
||||
│ └── .env.example
|
||||
├── frontend/ # Vue 3 SPA
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # axios instance and endpoint modules
|
||||
│ │ ├── components/ # shell, field and template components
|
||||
│ │ ├── components/ # shell, field, template and paper components
|
||||
│ │ ├── 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
|
||||
│ │ ├── utils/ # formatting helpers
|
||||
│ │ └── views/ # route-level components
|
||||
│ │ └── views/ # route-level components (papers/, templates/)
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
└── docs/ # project documentation
|
||||
@@ -278,3 +391,16 @@ cd backend
|
||||
.venv/bin/python scripts/seed.py # add anything missing
|
||||
.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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user