Skip to content

Commit 46c57d0

Browse files
committed
feat: 6 个新能力打包 + Docker compose + Story System 主合约
把"剩下的方向"一次做掉: 1. 写章前预检 (scripts/preflight.py) - 大纲 / 角色约束 / 卷号 / LLM API 4 项检查 - jieba 词性过滤减少误报 - cmd_draft + cmd_batch_draft 自动调,--no-preflight 关 2. Docker compose 一键起 (Dockerfile / docker-compose.yml / .dockerignore) - fastapi + streamlit 两 service 共享 image - react-dev profile 单独起,平时不用 - .env.docker.example 给参考配置 3. 角色对白样本自动刷新 - 写完一章命中 chapter % 30 == 0 自动跑 extract_character_voice.py - WEBNOVEL_VOICE_AUTO=0 关 / WEBNOVEL_VOICE_INTERVAL=N 改间隔 4. 卷头 transition 章特别注入 - extract_chapter_context._detect_volume_transition 检测卷头 - 注入"上卷尾章 + 新卷阶段支架段" - prompt_builder 给 LLM 一段"卷头 transition 注入" 5. 自动跑 L2 / 段尾 L3 - WEBNOVEL_AUTO_CHECK=L2 (默认) / L3 / off - chapter%10 自动 L2, chapter%80 自动 L3,异常落盘 审查报告/ - 用户配的监控 API (MONITORING_*) 终于派上用场 6. RAG 反向检索防桥段复读 - render_anti_repeat:RAG hits 里 score >= 0.7 视为"已写过" - 反向注入"避免和这几段重复"指令,300+ 章长篇必备 7. Story System 主合约层 (设定集/故事合约.md) - 缺文件不影响 (向后兼容) - 存在则注入 system prompt 顶部"全书宪法,优先于一切单章细节" - 双 demo 各加一份示例 - README 同类项目对比表 ✗ → ✓
1 parent a4e20de commit 46c57d0

13 files changed

Lines changed: 839 additions & 4 deletions

File tree

.dockerignore

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
.pytest_cache/
5+
.coverage
6+
htmlcov/
7+
.venv/
8+
venv/
9+
10+
# Node
11+
node_modules/
12+
*.log
13+
14+
# Editor
15+
.vscode/
16+
.idea/
17+
.DS_Store
18+
19+
# Local
20+
.env
21+
.env.*
22+
!.env.example
23+
.webnovel/
24+
books/
25+
*.bak
26+
27+
# Tests / docs we don't need in image
28+
docs/img/
29+
*.pdf

.env.docker.example

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# docker-compose 用,跟 webnovel-writer/templates/workspace/.env.example 字段一致
2+
# cp 到 .env 然后填:
3+
# cp .env.docker.example .env
4+
5+
# 哪本书(books/<BOOK>/)
6+
BOOK=default
7+
8+
# 写作 API
9+
LLM_PROVIDER=openai_compatible
10+
LLM_BASE_URL=https://api.deepseek.com
11+
LLM_CHAT_MODEL=deepseek-chat
12+
LLM_REASONING_MODEL=deepseek-reasoner
13+
DEEPSEEK_API_KEY=
14+
15+
# 网关备选(用 LLM_BASE_URL=https://api.mrxoad.uk/api/deepseek 时填)
16+
# LLM_GATEWAY_TOKEN=
17+
18+
# 监控 API(可选,缺省 fallback 到写作 API)
19+
# MONITORING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
20+
# MONITORING_CHAT_MODEL=qwen-turbo-latest
21+
# MONITORING_API_KEY=

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
.env
44
.env.*
55
!.env.example
6+
!.env.docker.example
67

78
# Python caches & virtualenv
89
__pycache__/

Dockerfile

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# 一镜像两用:fastapi 后端 + streamlit vibe 面板共享同一个 image
2+
FROM python:3.12-slim
3+
4+
ENV PYTHONUNBUFFERED=1 \
5+
PYTHONDONTWRITEBYTECODE=1 \
6+
PIP_NO_CACHE_DIR=1
7+
8+
# 系统最小依赖
9+
RUN apt-get update && apt-get install -y --no-install-recommends \
10+
git curl \
11+
&& rm -rf /var/lib/apt/lists/*
12+
13+
WORKDIR /app
14+
15+
# 先 copy requirements 利用 docker layer cache
16+
COPY requirements.txt ./requirements.txt
17+
COPY webnovel-writer/scripts/requirements.txt ./webnovel-writer/scripts/requirements.txt
18+
COPY webnovel-writer/dashboard/requirements.txt ./webnovel-writer/dashboard/requirements.txt
19+
20+
RUN pip install --upgrade pip \
21+
&& pip install -r requirements.txt \
22+
&& pip install streamlit plotly pandas numpy
23+
24+
# 再 copy 源码
25+
COPY . .
26+
27+
# 默认入口由 docker-compose 覆盖,这里给个保底
28+
CMD ["python", "-c", "print('xuanji-write image ready. 用 docker-compose 起 fastapi/streamlit 服务。')"]

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ Streamlit vibe(进度面板):
3939
| token / cost 自动统计 |||||
4040
| 角色对白历史样本注入 ||| character_state ||
4141
| 伏笔自动追踪 |||| propagation debt |
42-
| 长程上下文混合(5 类来源) |||||
43-
| Story System 主合约 | ✗(待补) ||| 五层共演化 |
42+
| 长程上下文混合(5 类来源 + 卷头 transition) |||||
43+
| Story System 主合约 | ✓(`设定集/故事合约.md`) ||| 五层共演化 |
44+
| RAG 反向检索防桥段复读 |||||
4445
| 多 agent 协作 |||||
4546

4647
##
@@ -51,6 +52,16 @@ cd xuanji-write
5152
python -m pip install -r requirements.txt
5253
```
5354

55+
或者用 Docker 一键起 fastapi 后端 + streamlit vibe 面板:
56+
57+
```bash
58+
cp .env.docker.example .env # 填 DEEPSEEK_API_KEY
59+
mkdir -p books && cp -r examples/demo-都市悬疑 books/default
60+
docker-compose up
61+
# Streamlit: http://localhost:8866
62+
# FastAPI: http://localhost:8000
63+
```
64+
5465
## 配 LLM
5566

5667
`.env` 最小配置(写正文):

docker-compose.yml

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# 一键起 streamlit + react + fastapi
2+
# 用法:
3+
# 1. 把书项目放到 ./books/<your_book>/
4+
# 2. 复制 .env.example 到当前目录 .env, 填 DEEPSEEK_API_KEY
5+
# 3. docker-compose up
6+
# 4. 访问:
7+
# - Streamlit vibe panel: http://localhost:8866
8+
# - React dashboard: http://localhost:5173 (dev) 或 http://localhost:8000 (prod)
9+
10+
services:
11+
fastapi:
12+
# FastAPI 后端 + 静态托管 dist/(prod 模式)
13+
build:
14+
context: .
15+
dockerfile: Dockerfile
16+
image: xuanji-write:latest
17+
container_name: xuanji-fastapi
18+
working_dir: /app/webnovel-writer
19+
command: >
20+
python -m dashboard.server
21+
--project-root /books/${BOOK:-default}
22+
--port 8000
23+
--host 0.0.0.0
24+
--no-browser
25+
environment:
26+
- LLM_PROVIDER=${LLM_PROVIDER:-openai_compatible}
27+
- LLM_BASE_URL=${LLM_BASE_URL:-https://api.deepseek.com}
28+
- LLM_CHAT_MODEL=${LLM_CHAT_MODEL:-deepseek-chat}
29+
- LLM_REASONING_MODEL=${LLM_REASONING_MODEL:-deepseek-reasoner}
30+
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
31+
- LLM_GATEWAY_TOKEN=${LLM_GATEWAY_TOKEN:-}
32+
- MONITORING_BASE_URL=${MONITORING_BASE_URL:-}
33+
- MONITORING_API_KEY=${MONITORING_API_KEY:-}
34+
- MONITORING_CHAT_MODEL=${MONITORING_CHAT_MODEL:-}
35+
ports:
36+
- "8000:8000"
37+
volumes:
38+
- ./books:/books
39+
- ./webnovel-writer:/app/webnovel-writer
40+
restart: unless-stopped
41+
42+
streamlit:
43+
# Streamlit "vibe" 实时面板
44+
image: xuanji-write:latest
45+
container_name: xuanji-streamlit
46+
depends_on:
47+
- fastapi
48+
working_dir: /app
49+
command: >
50+
streamlit run webnovel-writer/dashboard_vibe.py
51+
--server.port 8866
52+
--server.address 0.0.0.0
53+
--
54+
--project-root /books/${BOOK:-default}
55+
environment:
56+
- LLM_PROVIDER=${LLM_PROVIDER:-openai_compatible}
57+
- LLM_BASE_URL=${LLM_BASE_URL:-https://api.deepseek.com}
58+
- LLM_CHAT_MODEL=${LLM_CHAT_MODEL:-deepseek-chat}
59+
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY:-}
60+
- LLM_GATEWAY_TOKEN=${LLM_GATEWAY_TOKEN:-}
61+
ports:
62+
- "8866:8866"
63+
volumes:
64+
- ./books:/books
65+
- ./webnovel-writer:/app/webnovel-writer
66+
restart: unless-stopped
67+
68+
# ──────────────────────────────────────────
69+
# React dev server (只在用 docker-compose --profile dev up 时启动)
70+
# 平时不用,用户手工本地跑 npm run dev 更快
71+
# ──────────────────────────────────────────
72+
react-dev:
73+
image: node:20-slim
74+
container_name: xuanji-react-dev
75+
profiles: ["dev"]
76+
working_dir: /frontend
77+
command: sh -c "npm ci && npm run dev -- --host 0.0.0.0 --port 5173"
78+
ports:
79+
- "5173:5173"
80+
volumes:
81+
- ./webnovel-writer/dashboard/frontend:/frontend
82+
- /frontend/node_modules
83+
depends_on:
84+
- fastapi
85+
environment:
86+
- VITE_PROXY_TARGET=http://fastapi:8000
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# 故事合约(Story System)
2+
3+
> 这份文件被 webnovel-codex 自动注入 system prompt,定义全书宪法,优先级高于单章大纲。
4+
5+
## 核心冲突
6+
7+
少年沈砚之(凡人药童)误食千年灵芝后觉醒"听药脉"的天赋。青鸾宗内派系倾轧,他要在被当成耗材的命运里,凭借识药的独门本事一步步上位。
8+
9+
## 题材合约
10+
11+
- **修仙+宗门**:每三章一次小突破,每十章一次大节点
12+
- **慢热**:不是开局就拿"金手指爽",前 5 章主角处境凄惨
13+
- **稳定爽点**:每两章必有一次"被瞧不起 → 用识药能力打脸"的微弱反转
14+
- **不堆术语**:境界名简单(炼气 / 筑基 / 金丹),不自创复杂体系
15+
16+
## 主角弧
17+
18+
- 起点:被外门弟子欺压的杂役药童
19+
- 中段:成为宗门长老的"药脉鉴定师",但还是炼气期
20+
- 终段:接近金丹时发现宗门当年灭门血案的内幕,选择揭开真相
21+
22+
## 主反派阵营
23+
24+
青鸾宗当年就是被谁灭门的?宗门长老知情不报,这是主角弧后期的核心矛盾。
25+
26+
## 节奏曲线
27+
28+
- 开篇(1-3 章):误食灵芝觉醒能力
29+
- 第一节点(第 8 章):用识药本事在炼丹比试中胜过外门弟子
30+
- 中点(第 30 章):被长老收为药脉鉴定师
31+
- 低谷(第 50 章):被栽赃毁丹炉,降级
32+
- 收束(第 80 章):金丹大成,选择不报仇也不和解
33+
34+
## 写作纪律
35+
36+
- **主角永远叫"沈砚之"**
37+
- **门派永远是"青鸾宗"**
38+
- **境界只用"炼气 / 筑基 / 金丹"**,不写"渡劫 / 元婴 / 大乘"
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# 故事合约(Story System)
2+
3+
> 这份文件被 webnovel-codex 自动注入 system prompt,定义全书宪法,优先级高于单章大纲。
4+
> 改这份文件相当于改全书定调,不到必要不动。
5+
6+
## 核心冲突
7+
8+
陈晓白(前刑警 / 网约车司机)在五年前办砸的林雪失踪案与本周 S 市新发的连环案中找到同一种凶手手法。他要在体制外、不靠警权,把这个跨度 5 年的链路重新挖出来。
9+
10+
## 题材合约(给读者的体验承诺)
11+
12+
- **悬疑+程序**:每两章一条新线索,每六章一次小翻案
13+
- **都市冷感**:城市夜晚 / 网约车 / 老旧居民楼 / 凌晨电台
14+
- **不堆血腥**:恐怖来自"差一点就抓到"的张力,不来自尸体描写
15+
- **不写超自然**:所有怪事最后必有现实解释
16+
17+
## 主角弧
18+
19+
- 起点(1-15 章):自我封闭,只想跑车糊口,逃避旧案
20+
- 中段(16-30 章):被新案逼回旧案,以"顾问"非警察身份介入
21+
- 终段(31-50 章):亲手抓到凶手,但接受自己不再回到体制内
22+
23+
## 主反派弧(周鸣)
24+
25+
- 表面:S 市社区医院年轻医生,温和,邻居印象好
26+
- 暗面:五年前林雪案就是他做的,这五年用医院身份接近独居女性
27+
- 触发:林栀的"姐妹寻人"电台节目让他重新活跃,他主动给电台投稿"猎物自白"
28+
29+
## 节奏曲线
30+
31+
- 开篇钩(第 1 章):末班车女乘客失踪 + 口红刻字"L. 雪"
32+
- 第一拐点(第 8 章):陈晓白发现耳环数字 805 = 林雪当年丢的耳坠
33+
- 中点反转(第 25 章):陈晓白怀疑过的同事其实是无辜
34+
- 最低点(第 40 章):凶手拿到林栀,陈晓白警权被吊销
35+
- 高潮(第 48 章):12 楼对峙,无武器
36+
- 收束(第 50 章):凶手被捕,陈晓白离开警局,继续跑车,给林栀寄一封无名信
37+
38+
## 主线 / 副线
39+
40+
- 主线:连环失踪案
41+
- 副线 A(感情):林栀和陈晓白通过电台 / 邮件 / 信件慢慢靠近,但全书不超过一次见面
42+
- 副线 B(职场):李建国(刑警队老搭档)在体制内策应陈晓白
43+
- 副线 C(过去):林雪失踪案的真相通过本周新案逐步浮现
44+
45+
## 节奏强约束
46+
47+
- 每 3 章必有一次"案情推进或回退",不允许 5 章以上原地打转
48+
- 凶手视角章节最多占全书 1/5,不允许大量篇幅
49+
- 林栀视角的电台旁白每 4-6 章用一次,作为节奏调节
50+
51+
## 写作纪律
52+
53+
- **不写"丹田 / 经脉 / 修为"** — 题材是现代悬疑,不是修仙
54+
- **主角名永远是"陈晓白"**,不要写成"陈晓白"以外的同姓变体
55+
- **林雪是"林栀"的姐姐**,这两个名字不要互串
56+
- **周鸣的医生身份直到第 18 章才能被读者怀疑**,前 17 章他只是邻居 NPC

webnovel-writer/scripts/data_modules/tests/test_llm_adapter.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,8 @@ def test_cmd_draft_reuses_existing_chapter_and_syncs_outputs(tmp_path, monkeypat
387387
overwrite=True,
388388
stdout_only=False,
389389
use_volume_layout=False,
390+
no_preflight=True,
391+
preflight_strict=False,
390392
)
391393

392394
assert module.cmd_draft(args) == 0
@@ -434,6 +436,8 @@ def test_cmd_write_errors_when_outline_missing(tmp_path, monkeypatch):
434436
overwrite=True,
435437
stdout_only=False,
436438
use_volume_layout=False,
439+
no_preflight=True,
440+
preflight_strict=False,
437441
)
438442

439443
with pytest.raises(ValueError, match="缺少可用大纲"):
@@ -531,6 +535,8 @@ def test_cmd_write_updates_total_words_for_output_outside_project_root(tmp_path,
531535
overwrite=True,
532536
stdout_only=False,
533537
use_volume_layout=False,
538+
no_preflight=True,
539+
preflight_strict=False,
534540
)
535541

536542
assert module.cmd_draft(args) == 0

0 commit comments

Comments
 (0)