diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..1552911
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,176 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project overview
+
+WordFormat is a Python CLI tool that checks and auto-corrects formatting in academic Word documents (.docx). It uses an ONNX BERT model to classify paragraphs (heading, abstract, body text, keywords, references, etc.), then validates and applies formatting rules defined in a YAML config file.
+
+**Entry points**: `wordf` and `wordformat` commands both map to `wordformat.cli:main`.
+
+## Git conventions
+
+- **不要添加 `Co-Authored-By` 到 commit message 中。**
+
+## Build & test commands
+
+```bash
+# Install dev environment (creates venv, installs deps, downloads ONNX model)
+make install
+
+# Run all tests (with coverage, must reach 85%)
+make tests
+
+# Run a single test file
+pytest tests/test_rules.py -v
+
+# Run a specific test
+pytest tests/test_rules.py::TestAbstractTitleContentENBase -v
+
+# Run tests matching a keyword
+pytest tests/ -k "Abstract" -v
+
+# Lint & format
+ruff check src/
+ruff format src/
+
+# Run API server locally
+make server
+# or: wordf startapi
+
+# Lint only (no tests)
+make lint
+
+# Build distributable package
+make build
+
+# Build Vue UI and copy into api/static
+make build-ui
+
+# Clean build artifacts
+make clean
+
+# Run pre-commit checks on all files
+pre-commit run --all-files
+```
+
+## Install variants
+
+```bash
+pip install -e "." # core only
+pip install -e ".[api]" # core + FastAPI server
+pip install -e ".[test]" # core + pytest plugins
+pip install -e ".[dev]" # everything (api, test, pre-commit, ruff, pyinstaller)
+```
+
+## Pre-commit hooks
+
+Pre-commit runs on `pre-commit` and `pre-push` stages, configured in `.pre-commit-config.yaml`:
+
+- **sync-version** — mirrors `pyproject.toml` version into `_version.py`
+- **end-of-file-fixer** / **trailing-whitespace** / **debug-statements** / **check-yaml** / **pretty-format-json** — standard fixers
+- **pyupgrade** — auto-modernizes Python syntax (`--py3-plus`)
+- **ruff** — lint + format (auto-fix)
+
+## Ruff config (pyproject.toml)
+
+- **Line length**: 108 (pycodestyle), 200 (docstrings)
+- **Complexity**: max 10 (mccabe)
+- **Quote style**: double, space indent
+- **Per-file ignores**: `__init__.py` (F401/F403/E501), `tree.py`/`cli.py` (T201 print), `body.py`/`heading.py`/`numbering.py`/`set_style.py`/`utils.py` (C901 complexity)
+
+## Environment variables
+
+| Variable | Purpose | Default |
+|----------|---------|---------|
+| `WORDFORMAT_BASE_DIR` | Override working directory | auto-detected from project root |
+| `WORDFORMAT_API_KEY` | API key for model service | `""` |
+| `WORDFORMAT_MODEL` | Model identifier | `""` |
+| `WORDFORMAT_MODEL_URL` | Model service URL | `""` |
+| `BATCH_SIZE` | ONNX inference batch size | `64` |
+| `HOST` / `PORT` | API server bind | `127.0.0.1` / `8000` |
+
+## Architecture: core data flow
+
+The tool operates in two phases that are intentionally separated so users can inspect and manually adjust the intermediate JSON before applying formatting:
+
+```
+wordf gj (generate JSON) wordf cf / wordf af (check/apply format)
+───────────────────── ───────────────────────────────────
+.docx → ONNX classify → JSON tree → match paragraphs by
+ flat JSON per para position order (zip) →
+ per-node style check/apply
+```
+
+**Phase 1 — Classification** (`set_tag.py` → `base.py`):
+- `DocxBase.parse()` loads a .docx, iterates paragraphs, batches them through ONNX BERT inference (`agent/onnx_infer.py`), and returns a flat list of `{category, text, score, comment}` dicts saved as JSON.
+
+**Phase 2 — Tree building** (`word_structure/`):
+- `DocumentBuilder.build_from_json()` feeds the flat JSON list into `DocumentTreeBuilder.build_tree()`, which creates a hierarchical `FormatNode` tree. `LEVEL_MAP` in `word_structure/settings.py` maps category strings to numeric levels; `CATEGORY_TO_CLASS` maps categories to `FormatNode` subclasses.
+- `node_factory.create_node()` instantiates the right `FormatNode` subclass and calls `load_config()`, which resolves the node's `CONFIG_PATH` (e.g. `"abstract.english"`) against the Pydantic root config model (`NodeConfigRoot`).
+
+**Phase 3 — Matching & formatting** (`set_style.py`):
+- Each paragraph in the document is matched to a tree node by position order: `_flatten_tree_nodes()` converts the tree to a flat DFS list, then `zip()` pairs nodes with `document.paragraphs` by index.
+- Before formatting, `node.apply_replace(doc)` checks for a `replace` field in the JSON value dict; if present, it substitutes the paragraph's run text with the replacement string.
+- The tree is also mutated: `promote_bodytext_in_subtrees_of_type()` replaces generic `body_text` nodes under specific parents (e.g. `AbstractTitleCN`) with typed content nodes (e.g. `AbstractContentCN`).
+- `apply_format_check_to_all_nodes()` recursively traverses the tree. For each node it calls `node.check_format(doc)` or `node.apply_format(doc)`, which delegate to `node._base(doc, p, r)` — the boolean flags control whether paragraph styles (`p`) and run styles (`r`) are checked (diffed) or applied (written).
+
+**Phase 4 — Numbering** (apply mode only, `numbering.py`):
+- `process_heading_numbering()` strips manual heading numbers from run text and applies Word auto-numbering definitions.
+
+## Key directories & files
+
+| Path | Purpose |
+|------|---------|
+| `src/wordformat/cli.py` | CLI entry point (`gj`/`cf`/`af`/`tree`/`startapi` subcommands) |
+| `src/wordformat/set_style.py` | Orchestrator: tree flattening (`_flatten_tree_nodes`), position-based paragraph matching, subtree promotion, text replace, calls check/apply on each node |
+| `src/wordformat/set_tag.py` | Classification entry point: loads doc, calls `DocxBase`, returns JSON |
+| `src/wordformat/base.py` | `DocxBase`: docx loading + ONNX batch inference |
+| `src/wordformat/rules/node.py` | `FormatNode` base class with `load_config`, `check_format`, `apply_format`, `add_comment` |
+| `src/wordformat/rules/abstract.py` | Abstract title/content/title-content nodes (CN + EN) |
+| `src/wordformat/rules/heading.py` | Heading level 1/2/3 nodes with custom config loading |
+| `src/wordformat/rules/keywords.py` | Keywords nodes with tag detection and count validation |
+| `src/wordformat/style/check_format.py` | `CharacterStyle` (run-level) and `ParagraphStyle` (para-level) with diff/apply logic |
+| `src/wordformat/style/style_enum.py` | Enums: `FontSize`, `FontColor`, `FontName`, `Alignment`, `LineSpacingRule`, etc. |
+| `src/wordformat/config/datamodel.py` | Pydantic v2 models for all config sections (`GlobalFormatConfig`, `AbstractConfig`, `HeadingsConfig`, `NumberingConfig`, etc.) |
+| `src/wordformat/config/config.py` | `LazyConfig` singleton for YAML loading and caching |
+| `src/wordformat/numbering.py` | Heading auto-numbering (clear manual + apply Word numbering) |
+| `src/wordformat/tree.py` | `Tree`, `TreeNode`, `Stack` data structures |
+| `src/wordformat/word_structure/` | Tree building from flat JSON (`DocumentBuilder`, `DocumentTreeBuilder`, `node_factory`, `settings`) |
+| `src/wordformat/api/__init__.py` | FastAPI app (unusual: defined in `__init__.py`, not a separate module). 3 main endpoints + download. Serves Vue SPA from `api/static/`. |
+| `src/wordformat/agent/` | `onnx_infer.py` (ONNX model inference), `message.py` (messaging) |
+| `src/wordformat/settings.py` | Global config: `BASE_DIR`, `SERVER_HOST`, model/env settings |
+| `tests/` | 5 test files with ~850 tests, ~93% coverage |
+| `presets/` | Per-university preset YAML configs |
+| `wordformat-skill/` | AI assistant skill definition for Claude Code integration |
+
+## FormatNode subclass pattern
+
+Each `FormatNode` subclass declares three class-level attributes and implements `_base(doc, p, r)`:
+
+- `NODE_TYPE` — dot-separated path used by `TreeNode.load_config()` to extract dict config from the full YAML tree
+- `CONFIG_MODEL` — Pydantic model class for type-safe config access via `self.pydantic_config`
+- `CONFIG_PATH` — dot-separated path used by `FormatNode.load_config()` to resolve the Pydantic config object via `getattr`
+
+When adding a new node type, register it in `word_structure/settings.py` (`CATEGORY_TO_CLASS` and `LEVEL_MAP`) and in `rules/__init__.py`.
+
+## Test conventions
+
+- Tests are organized by module: `test_core.py` (tree, stack, numbering, utils, DocxBase), `test_style.py` (style enums, CharacterStyle, ParagraphStyle), `test_rules.py` (all FormatNode subclasses), `test_integration.py` (config, cross-module, CLI, API), `test_coverage_boost.py` (edge case coverage).
+- Known bugs are documented in tests with `"""BUG: ..."""` docstrings — the test asserts the current (buggy) behavior. When fixing, update the test to assert the correct behavior.
+- Tests use `python-docx` `Document()` to create in-memory test docs rather than real .docx files. Patches are applied via `unittest.mock.patch` at the module level (e.g. `patch("wordformat.base.onnx_batch_infer", ...)`).
+- `conftest.py` provides shared fixtures: `doc` (in-memory Document), a mock ONNX session, and config reset between tests.
+
+## Config model structure
+
+The YAML config is validated by `NodeConfigRoot` (in `datamodel.py`), which wraps:
+- `abstract: AbstractConfig` → `chinese: AbstractChineseConfig`, `english: AbstractEnglishConfig`
+- `headings: HeadingsConfig` → `level_1/level_2/level_3: HeadingLevelConfig`
+- `body_text: GlobalFormatConfig`
+- `references: GlobalFormatConfig`
+- `captions: dict[str, GlobalFormatConfig]`
+- `numbering: NumberingConfig`
+- `style_checks_warning: WarningFieldConfig`
+- `replace: dict[str, str]`
+
+`GlobalFormatConfig` carries all glyph + paragraph format fields (font, size, color, bold, italic, alignment, spacing, indent, etc.). `AbstractChineseConfig` and `AbstractEnglishConfig` each hold a `title` and `content` sub-config (both `GlobalFormatConfig` subclasses), enabling `AbstractTitleContentCN`/`AbstractTitleContentEN` to apply different styles to title-runs vs content-runs within the same paragraph.
diff --git a/WordFormatUI/src/App.vue b/WordFormatUI/src/App.vue
index 6277724..c4a17cb 100644
--- a/WordFormatUI/src/App.vue
+++ b/WordFormatUI/src/App.vue
@@ -1,66 +1,35 @@
{{ yamlContent }}Y?Ft(p,S,E,!0,!1,P):ie(b,x,O,S,E,L,$,N,P)},$e=(p,b,x,O,S,E,L,$,N)=>{let T=0;const Y=b.length;let P=p.length-1,K=Y-1;for(;T<=P&&T<=K;){const z=p[T],ce=b[T]=N?It(b[T]):St(b[T]);if(xn(z,ce))g(z,ce,x,null,S,E,L,$,N);else break;T++}for(;T<=P&&T<=K;){const z=p[P],ce=b[K]=N?It(b[K]):St(b[K]);if(xn(z,ce))g(z,ce,x,null,S,E,L,$,N);else break;P--,K--}if(T>P){if(T<=K){const z=K+1,ce=z=48&&y<=57||y>=65&&y<=70||y>=97&&y<=102)&&(g>=48&&g<=57||g>=65&&g<=70||g>=97&&g<=102)&&(l-=2,m+=2)}let a=0,u=s-1;const f=m=>m>=2&&i.charCodeAt(m-2)===37&&i.charCodeAt(m-1)===51&&(i.charCodeAt(m)===68||i.charCodeAt(m)===100);u>=0&&(i.charCodeAt(u)===61?(a++,u--):f(u)&&(a++,u-=3)),a===1&&u>=0&&(i.charCodeAt(u)===61||f(u))&&a++;const h=Math.floor(l/4)*3-(a||0);return h>0?h:0}if(typeof Buffer<"u"&&typeof Buffer.byteLength=="function")return Buffer.byteLength(i,"utf8");let o=0;for(let l=0,s=i.length;l=55296&&a<=56319&&l+1=56320&&u<=57343?(o+=4,l++):o+=3}else o+=3}return o}const $o="1.16.0",Vl=64*1024,{isFunction:Oi}=v,Hl=(e,...t)=>{try{return!!e(...t)}catch{return!1}},_g=e=>{const t=v.global??globalThis,{ReadableStream:n,TextEncoder:i}=t;e=v.merge.call({skipUndefined:!0},{Request:t.Request,Response:t.Response},e);const{fetch:r,Request:o,Response:l}=e,s=r?Oi(r):typeof fetch=="function",a=Oi(o),u=Oi(l);if(!s)return!1;const f=s&&Oi(n),d=s&&(typeof i=="function"?(C=>w=>C.encode(w))(new i):async C=>new Uint8Array(await new o(C).arrayBuffer())),h=a&&f&&Hl(()=>{let C=!1;const w=new o(Me.origin,{body:new n,method:"POST",get duplex(){return C=!0,"half"}}),I=w.headers.has("Content-Type");return w.body!=null&&w.body.cancel(),C&&!I}),m=u&&f&&Hl(()=>v.isReadableStream(new l("").body)),y={stream:m&&(C=>C.body)};s&&["text","arrayBuffer","blob","formData","stream"].forEach(C=>{!y[C]&&(y[C]=(w,I)=>{let R=w&&w[C];if(R)return R.call(w);throw new D(`Response type '${C}' is not supported`,D.ERR_NOT_SUPPORT,I)})});const g=async C=>{if(C==null)return 0;if(v.isBlob(C))return C.size;if(v.isSpecCompliantForm(C))return(await new o(Me.origin,{method:"POST",body:C}).arrayBuffer()).byteLength;if(v.isArrayBufferView(C)||v.isArrayBuffer(C))return C.byteLength;if(v.isURLSearchParams(C)&&(C=C+""),v.isString(C))return(await d(C)).byteLength},_=async(C,w)=>{const I=v.toFiniteNumber(C.getContentLength());return I??g(w)};return async C=>{let{url:w,method:I,data:R,signal:G,cancelToken:ae,timeout:J,onDownloadProgress:ie,onUploadProgress:B,responseType:X,headers:k,withCredentials:A="same-origin",fetchOptions:V,maxContentLength:M,maxBodyLength:se}=Va(C);const Q=v.isNumber(M)&&M>-1,ee=v.isNumber(se)&&se>-1;let re=r||fetch;X=X?(X+"").toLowerCase():"text";let we=gg([G,ae&&ae.toAbortSignal()],J),$e=null;const He=we&&we.unsubscribe&&(()=>{we.unsubscribe()});let tt;try{if(Q&&typeof w=="string"&&w.startsWith("data:")&&vg(w)>M)throw new D("maxContentLength size of "+M+" exceeded",D.ERR_BAD_RESPONSE,C,$e);if(ee&&I!=="get"&&I!=="head"){const de=await _(k,R);if(typeof de=="number"&&isFinite(de)&&de>se)throw new D("Request body larger than maxBodyLength limit",D.ERR_BAD_REQUEST,C,$e)}if(B&&h&&I!=="get"&&I!=="head"&&(tt=await _(k,R))!==0){let de=new o(w,{method:"POST",body:R,duplex:"half"}),Nt;if(v.isFormData(R)&&(Nt=de.headers.get("content-type"))&&k.setContentType(Nt),de.body){const[bt,Bn]=Ul(tt,Ki(Ml(B)));R=Bl(de.body,Vl,bt,Bn)}}v.isString(A)||(A=A?"include":"omit");const ke=a&&"credentials"in o.prototype;if(v.isFormData(R)){const de=k.getContentType();de&&/^multipart\/form-data/i.test(de)&&!/boundary=/i.test(de)&&k.delete("content-type")}k.set("User-Agent","axios/"+$o,!1);const mt={...V,signal:we,method:I.toUpperCase(),headers:k.normalize().toJSON(),body:R,duplex:"half",credentials:ke?A:void 0};$e=a&&new o(w,mt);let ot=await(a?re($e,V):re(w,mt));if(Q){const de=v.toFiniteNumber(ot.headers.get("content-length"));if(de!=null&&de>M)throw new D("maxContentLength size of "+M+" exceeded",D.ERR_BAD_RESPONSE,C,$e)}const Ft=m&&(X==="stream"||X==="response");if(m&&ot.body&&(ie||Q||Ft&&He)){const de={};["status","statusText","headers"].forEach(x=>{de[x]=ot[x]});const Nt=v.toFiniteNumber(ot.headers.get("content-length")),[bt,Bn]=ie&&Ul(Nt,Ki(Ml(ie),!0))||[];let p=0;const b=x=>{if(Q&&(p=x,p>M))throw new D("maxContentLength size of "+M+" exceeded",D.ERR_BAD_RESPONSE,C,$e);bt&&bt(x)};ot=new l(Bl(ot.body,Vl,b,()=>{Bn&&Bn(),He&&He()}),de)}X=X||"text";let Xe=await y[v.findKey(y,X)||"text"](ot,C);if(Q&&!m&&!Ft){let de;if(Xe!=null&&(typeof Xe.byteLength=="number"?de=Xe.byteLength:typeof Xe.size=="number"?de=Xe.size:typeof Xe=="string"&&(de=typeof i=="function"?new i().encode(Xe).byteLength:Xe.length)),typeof de=="number"&&de>M)throw new D("maxContentLength size of "+M+" exceeded",D.ERR_BAD_RESPONSE,C,$e)}return!Ft&&He&&He(),await new Promise((de,Nt)=>{ja(de,Nt,{data:Xe,headers:Ge.from(ot.headers),status:ot.status,statusText:ot.statusText,config:C,request:$e})})}catch(ke){if(He&&He(),we&&we.aborted&&we.reason instanceof D){const mt=we.reason;throw mt.config=C,$e&&(mt.request=$e),ke!==mt&&(mt.cause=ke),mt}throw ke&&ke.name==="TypeError"&&/Load failed|fetch/i.test(ke.message)?Object.assign(new D("Network Error",D.ERR_NETWORK,C,$e,ke&&ke.response),{cause:ke.cause||ke}):D.from(ke,ke&&ke.code,C,$e,ke&&ke.response)}}},xg=new Map,Ha=e=>{let t=e&&e.env||{};const{fetch:n,Request:i,Response:r}=t,o=[i,r,n];let l=o.length,s=l,a,u,f=xg;for(;s--;)a=o[s],u=f.get(a),u===void 0&&f.set(a,u=s?new Map:_g(t)),f=u;return u};Ha();const Lo={http:Bh,xhr:hg,fetch:{get:Ha}};v.forEach(Lo,(e,t)=>{if(e){try{Object.defineProperty(e,"name",{__proto__:null,value:t})}catch{}Object.defineProperty(e,"adapterName",{__proto__:null,value:t})}});const ql=e=>`- ${e}`,wg=e=>v.isFunction(e)||e===null||e===!1;function Ag(e,t){e=v.isArray(e)?e:[e];const{length:n}=e;let i,r;const o={};for(let l=0;ls&&(s=e.lineIndent),Rt(d)){a++;continue}if(e.lineIndentt)&&a!==0)q(e,"bad indentation of a sequence entry");else if(e.lineIndent"u"&&Ht(e,t+1,null,!0,!0,!1,!0))&&((!i||r!=="")&&(r+=ao(e,t)),e.dump&&di===e.dump.charCodeAt(0)?r+="-":r+="- ",r+=e.dump);e.tag=o,e.dump=r||"[]"}function Vb(e,t,n){var i="",r=e.tag,o=Object.keys(n),l,s,a,u,f;for(l=0,s=o.length;l1024&&(f+="? "),f+=e.dump+(e.condenseFlow?'"':"")+":"+(e.condenseFlow?"":" "),Ht(e,t,u,!1,!1)&&(f+=e.dump,i+=f));e.tag=r,e.dump="{"+i+"}"}function Hb(e,t,n,i){var r="",o=e.tag,l=Object.keys(n),s,a,u,f,d,h;if(e.sortKeys===!0)l.sort();else if(typeof e.sortKeys=="function")l.sort(e.sortKeys);else if(e.sortKeys)throw new ze("sortKeys must be a boolean or a function");for(s=0,a=l.length;s1024,d&&(e.dump&&di===e.dump.charCodeAt(0)?h+="?":h+="? "),h+=e.dump,d&&(h+=ao(e,t)),Ht(e,t+1,f,!0,d)&&(e.dump&&di===e.dump.charCodeAt(0)?h+=":":h+=": ",h+=e.dump,r+=h));e.tag=o,e.dump=r||"{}"}function ss(e,t,n){var i,r,o,l,s,a;for(r=n?e.explicitTypes:e.implicitTypes,o=0,l=r.length;o