feat: Phase 0 — monorepo 骨架 + 全表迁移 + FastAPI/Next 骨架 + CI

- uv(workspace) + pnpm monorepo;docker-compose(pg+api+web)
- SQLAlchemy 16 MVP 表 + Alembic 初版迁移(无漂移,users stub)
- FastAPI 骨架:统一错误信封(带 request_id) + structlog + /jobs/:id + OpenAPI
- Next.js 骨架:纸感主题 token + OpenAPI→TS 客户端代码生成(gen:api)
- CI(ruff/mypy/pytest + pg service + alembic 漂移校验)
- 四份设计规格(PRODUCT/UX/ARCHITECTURE/DEV_PLAN) + CLAUDE.md
This commit is contained in:
Yaojia Wang
2026-06-18 11:38:28 +02:00
commit d3dc620a71
74 changed files with 12960 additions and 0 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Postgres (docker-compose 默认)
DATABASE_URL=postgresql+asyncpg://writer:writer@localhost:5432/writer
# 同步 URLAlembic 迁移用)
DATABASE_URL_SYNC=postgresql+psycopg://writer:writer@localhost:5432/writer
# 应用
APP_ENV=dev
LOG_JSON=false
# 凭据加密密钥Fernet, base64 32B— 原型占位
CREDENTIAL_ENC_KEY=

64
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: writer
POSTGRES_PASSWORD: writer
POSTGRES_DB: writer
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U writer"
--health-interval 5s --health-timeout 3s --health-retries 10
env:
DATABASE_URL: postgresql+asyncpg://writer:writer@localhost:5432/writer
DATABASE_URL_SYNC: postgresql+psycopg://writer:writer@localhost:5432/writer
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv sync
- name: ruff
run: uv run ruff check .
- name: mypy
run: uv run mypy packages apps
- name: alembic upgrade + drift check
run: |
uv run alembic upgrade head
uv run alembic check
- name: pytest
run: uv run pytest -q
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv sync
- uses: actions/setup-node@v4
with:
node-version: 22
- run: corepack enable
- name: install
working-directory: apps/web
run: pnpm install --frozen-lockfile
- name: gen api types
working-directory: apps/web
run: pnpm gen:api
- name: lint
working-directory: apps/web
run: pnpm lint
- name: typecheck
working-directory: apps/web
run: pnpm typecheck
- name: test
working-directory: apps/web
run: pnpm test

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# python
__pycache__/
*.py[cod]
.venv/
.uv/
.mypy_cache/
.pytest_cache/
.ruff_cache/
*.egg-info/
# node
node_modules/
.next/
apps/web/lib/api/openapi.json
# env / local
.env
.env.local
# claude code per-user local settings (machine-specific; not shared)
.claude/settings.local.json
# os
.DS_Store

960
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,960 @@
# 网文创作工作流 · 工程架构文档Architecture
> 把 [PRODUCT_SPEC.md](./PRODUCT_SPEC.md)(产品规格)下沉到**可实现的技术架构**:组件划分、接口契约、完整 DDL、LLM 网关内部设计、编排引擎、API 清单、横切关注点、部署与测试。
> UX/UI 见 [UX_SPEC.md](./UX_SPEC.md)。本文按 PRODUCT_SPEC 章节顺序逐节展开,每节末标注回溯锚点 `← PRODUCT_SPEC §x`。
---
## 0. 文档说明
| 项 | 内容 |
|---|---|
| 文档类型 | 软件架构文档SAD面向工程实现 |
| 目标读者 | 后端/前端工程师、技术负责人、SRE |
| 上游 | `PRODUCT_SPEC.md`(产品规格)、`UX_SPEC.md`(界面规格) |
| 范围 | 技术架构与设计;**不含**业务代码与脚手架(属 M1 实现阶段) |
| 状态 | 设计定稿待评审;评审通过后据此搭 M1 骨架 |
| 关键约束 | 提供商中立(不绑定单一 LLM 厂商记忆即真相源AI 不黑箱(产出经验收 gate |
**与上游的关系**PRODUCT_SPEC 回答"做什么/为什么",本文回答"怎么实现"。本文不重复产品论证,只在每节顶部用一句话点出对应的产品意图,随后给技术方案。如展开中发现规格需调整,集中列于 [§12 后 · 待回写规格的发现](#待回写规格的发现),不擅自改上游。
---
## 1. 架构总览 ← PRODUCT_SPEC §1 / §1.5 / §2
### 1.1 质量属性NFR与架构驱动
产品的四大痛点 + 工程哲学,转译为可度量的架构驱动力:
| 质量属性 | 目标 | 架构手段 |
|---|---|---|
| **一致性**(首要) | 几十万字不崩设定/不忘伏笔 | 记忆即真相源 + 每章事实摘要 + 写前检索 + 四审 gate |
| **可扩展(提供商)** | 任意接入/切换 LLM 厂商 | LLM 网关 + 适配器 + 能力档位抽象 |
| **可扩展(能力)** | 新增 Agent/题材模板零侵入 | 声明式 Skill + registry + 表权限契约 |
| **成本可控** | 长记忆注入不烧钱 | 前缀缓存抽象 + **确定性按需注入**(显式+近况,无向量) + 档位分流 |
| **可用性/韧性** | 单厂商故障不致瘫 | 故障回退链 + 重试/熔断 + 能力降级 |
| **安全/隔离** | 密钥与作品数据隔离 | 密钥托管 + 用户 Skill 沙箱(**原型单用户**;多租户行级隔离为后续,见 §9.1 |
| **可掌控(产品约束)** | AI 改动经作者裁决 | 四审只读 + 验收事务写回 |
| **可观测** | 每次 LLM 调用可追踪可计费 | 结构化日志 + LLM trace + 按 provider+model 记账 |
### 1.2 架构风格与关键决策ADR 摘要)
| # | 决策 | 选择 | 理由 / 取舍 |
|---|---|---|---|
| ADR-1 | 总体形态 | 前后端分离:**后端为模块化单体**Modular Monolith起步 | 前端 Next、后端 FastAPI 两个可部署单元;后端内部编排器/网关/记忆为清晰模块、预留拆服务接缝,避免过早微服务化。 |
| ADR-2 | 真相源 | **数据库为唯一真相源**Agent 经库交换 | 对齐 PRODUCT_SPEC §5.4Agent 间零直连,降耦合、可独立替换。 |
| ADR-3 | LLM 接入 | **自建网关 + 适配器**,直连各家 API | 对齐 §3.4;提供商中立、确定性编排;不用厂商 CLI/托管运行时(锁定单厂商)。 |
| ADR-4 | Agent 形态 | **声明式 Skill = 单一职责 Agent**;用 **LangGraph** 做确定性编排 | 对齐 §5非自治 agent loop编排为固定图节点/并行/验收 HITL 中断/checkpoint可测、可回放。 |
| ADR-5 | 写章模型 | **章 = 纯函数 f(大纲, 选择的状态, 文风)**,状态变更集中在验收 | 可缓存、可重放;副作用隔离到验收事务。 |
| ADR-6 | 检索 | **Postgres 单库 + 确定性记忆选择**(无向量) | 原型设定集不大,"本章有谁"大纲已点名,确定性选择(显式+主角+近况)足够且可调试;**向量检索降为 P2**,规模化再在同一接口后接入。 |
| ADR-7 | 结构化输出 | **网关统一封装**原生优先、JSON+校验兜底 | 各家能力参差§3.3 能力协商),上层无感。 |
| ADR-8 | 前后端 | **前端 Next.jsTS+ 后端 FastAPIPython分离** | 前端 SSR/流式友好;后端 Python 是 LLM 编排/工具的强生态;契约经 OpenAPI→TS 代码生成。 |
| ADR-9 | 长任务 | **FastAPI BackgroundTasks + `jobs` 表**(原型不上专门队列) | 学文风/全书扫描走后台任务 + 状态表;写章/审稿走 SSE 即时反馈。规模化再上 arq/Celery。 |
| ADR-10 | 编排实现 | **LangGraphPython** | 写章→四审→验收天然是跨请求的暂停-恢复HITLLangGraph 的并行/中断/checkpoint 正合,且 Python 版成熟。 |
### 1.3 系统上下文图C4 L1
```
┌───────────────┐
│ 网文作者 │ 浏览器
└──────┬────────┘
│ HTTPS
┌────────▼─────────────────────────────┐
│ 网文创作工作流(本系统) │
│ 立项·设定·大纲·写章·四审·验收·伏笔 │
└───┬──────────────────────────────────┘
│ 各家 API(经网关)
┌────────▼────────┐
│ LLM 提供商 │
│ Claude/DeepSeek │
│ Kimi/GPT/GLM/… │
└─────────────────┘
```
外部依赖①LLM 提供商(多家,经网关);②对象存储(可选,存文风样本原文/导出稿)。
> 注:原型**不依赖 Embedding 服务**砍向量检索改确定性记忆选择§3.4);向量检索为 P2届时再引入嵌入服务。
### 1.4 容器图C4 L2
```
┌──── 前端 Next.js(TS) ────┐ ┌──────────── 后端 FastAPI(Python) ────────────┐
│ 作品库 立项 工作台 审稿 │ fetch │ │
│ 设定库 伏笔看板 设置页 │ ─/SSE─▶ │ API 层 ─▶ 编排器(LangGraph) ─▶ LLM 网关 │
└──────────────────────────┘ │ │ │ │ ┌────────┐│
OpenAPI→TS 客户端类型 │ │ ▼ ├─▶│OpenAI兼容││ DeepSeek…
│ │ 记忆服务(选择/注入/写回) ├─▶│Anthropic ││
│ ▼ │ └─▶│Gemini ││
│ BackgroundTasks 数据访问层(Repo) 密钥管理 │
└────┬───────────────┬───────────────┬─────────┘
│(jobs) │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ jobs 表(同库)│ │ Postgres │ │ 密钥存储 │
│ │ │ (真相源,无向量)│ │ (KMS/加密列) │
└─────────────┘ └─────────────┘ └─────────────┘
```
**容器职责**
- **前端Next.js/TS**UX_SPEC 的页面与交互;流式渲染、冲突标注、乐观更新;经 OpenAPI 生成的 TS 客户端调后端。
- **后端FastAPI/Python**API 层入参校验、SSE、编排器LangGraph、网关、记忆服务同进程。
- **编排器LangGraph**:确定性图串联 Agent写章→四审→验收 HITL并行四审、验收中断/恢复。
- **记忆服务**:写前**确定性选择**(显式+主角+近况无向量、prompt 组装(缓存断点)、验收写回(事务)。
- **LLM 网关**:档位路由、适配器、能力协商/降级、回退、缓存、记账§4 详)。图中 **OpenAI 兼容适配器一套覆盖 DeepSeek/Kimi/Qwen/GLM/OpenAI**Anthropic/Gemini 各一适配器§4.2)。
- **数据访问层**Repository 模式封装所有表读写(原型单用户,多租户行级隔离为后续)。
- **BackgroundTasks + `jobs` 表**:学文风、全书扫描等长任务(原型不上专门队列)。
- **密钥管理**:各提供商 API Key 加密托管。
---
## 2. 技术栈与工程结构 ← PRODUCT_SPEC §3.1
### 2.1 选型与理由
| 层 | 选型 | 理由 |
|---|---|---|
| 前端语言/框架 | **TypeScript + Next.js App Router + React** | SSR/流式/编辑器友好;纯 UI调后端 API |
| 后端语言/框架 | **Python + FastAPI**async | LLM 编排/工具的强生态async + SSE + Pydantic 校验天然契合 |
| 样式 | Tailwind + CSS 变量(设计 token | 纸感主题 token 化§8.4);快速一致 |
| 编排 | **LangGraph**Python | 写章→四审→验收的并行 + HITL 中断 + checkpointADR-10 |
| 数据库 | **PostgreSQL**(无向量) | 关系一库;事务保证验收写回原子性;向量检索为 P2 再引入 |
| ORM/迁移 | **SQLAlchemy 2.0async+ Alembic** | 类型化 + 迁移管理;贴近 SQL/索引控制 |
| 长任务 | **FastAPI BackgroundTasks + `jobs` 表** | 原型免专门队列;规模化再上 arq/Celery |
| LLM SDK | `anthropic` + `openai`(baseURL 覆盖 DeepSeek/Kimi/Qwen/GLM) + `google-genai` | 网关内适配器按需选用 |
| 校验/结构化输出 | **Pydantic + `instructor`** | 入参 + 结构化输出 schema 单一来源 |
| 前后端契约 | **FastAPI OpenAPI → 生成 TS 客户端**openapi-typescript/orval | 找回"共享类型",跨语言不漂移 |
| 鉴权 | **原型单用户 stub**(多租户 + Auth 为后续) | 先做原型§9.1 |
| 观测 | OpenTelemetry + 结构化日志structlog | LLM 调用 trace、成本指标 |
> 说明:本架构约束**模块边界与契约**;上表为原型锁定栈,向量检索/专门队列/多租户均为后续可选(标注于各节)。
### 2.2 代码组织monorepo 模块边界)
```
writer-work-flow/
├── apps/
│ ├── web/ # 前端 Next.js(TS):纯 UI调后端 API
│ │ ├── app/ # App Router 页面(对齐 UX_SPEC §6)
│ │ │ ├── (dashboard)/ # 作品库
│ │ │ ├── projects/[id]/ # 工作台: write/outline/codex/foreshadow/review/style/rules
│ │ │ └── settings/ # 模型与提供商设置(UX §6.10)
│ │ ├── components/ # UI 组件(对齐 UX §7)
│ │ └── lib/api/ # OpenAPI 生成的 TS 客户端
│ └── api/ # 后端 FastAPI(Python)
│ ├── routers/ # 端点(对齐 §7)
│ └── main.py # 应用入口 + OpenAPI
├── packages/ # Python 包(后端共享)
│ ├── core/ # 领域核心(无 IO)
│ │ ├── domain/ # 实体/状态机(伏笔状态机/一致性规则)
│ │ ├── orchestrator/ # 编排器(LangGraph 图)
│ │ └── memory/ # 确定性选择/注入/prompt 组装
│ ├── llm_gateway/ # LLM 网关(适配器/路由/降级/回退/缓存/记账)
│ │ └── adapters/ # openai_compatible / anthropic / gemini
│ ├── agents/ # 8 个内置 Agent 的声明(prompt+schema+reads/writes)
│ ├── skills/ # Skill registry + loader + 权限沙箱
│ ├── db/ # SQLAlchemy 模型 / Alembic 迁移 / Repository
│ ├── shared/ # Pydantic schema / 错误信封(后端; 经 OpenAPI 暴露给前端)
│ └── config/ # 提供商配置/档位映射/env 解析
└── PRODUCT_SPEC.md / UX_SPEC.md / ARCHITECTURE.md / DEV_PLAN.md
```
**模块依赖方向**(高→低,禁止反向):
`apps/api(routers) → core / agents / skills → llm_gateway / memory → db → shared`
`core` 不依赖 `db` 的具体实现,只依赖 Repository 接口(依赖倒置,便于测试 mock
**前后端契约**`apps/api` 的 FastAPI 自动产出 OpenAPI → 生成 `apps/web/lib/api` 的 TS 类型,跨语言不漂移。
### 2.3 运行时拓扑
- **后端进程FastAPI**:处理 HTTP/SSE承载 API 层 + 编排器(LangGraph) + 网关 + 记忆服务(同进程,低延迟);长任务用 **BackgroundTasks** 在同进程后台跑,状态落 `jobs` 表(原型无独立 Worker常驻服务部署即可避免 serverless 杀长函数)。
- **前端进程Next.js**SSR + 静态资源;调后端 API。
- **Postgres**:单主库起步;读多可加只读副本(看板/列表查询走副本)。
- **横向扩展**:原型单实例即可;后端无状态,规模化可多实例 + 抽独立 Workerarq/Celery
---
## 3. 数据架构 ← PRODUCT_SPEC §3.2
产品意图:记忆即真相源,每章写前检索注入、验收后增量更新;不可变追加保留历史。
### 3.1 完整 DDL11 张创作表 + 运营表)
> 类型以 PostgreSQL 表述;`jsonb` 用于结构化但 schema 演进频繁的字段。所有业务表带 `project_id` 外键(原型单用户,多租户行级隔离为后续)。**原型无向量列**——记忆选择走确定性逻辑§3.4);向量检索为 P2届时再加 `embedding vector(N)` 列与 HNSW 索引。
> 下面先列 **11 张创作数据表**(对齐 PRODUCT_SPEC §3.2;其中 `timeline`/`decisions` 为 P2MVP 可不建),再列 **运营/系统表**(用户/凭据/用量/技能/jobs
```sql
-- 作品(根表,承载总纲/卖点/结构)
CREATE TABLE projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES users(id),
title text NOT NULL,
genre text,
logline text, -- 一句话故事
premise text, -- 总纲
theme text, -- 立意
selling_points jsonb DEFAULT '[]', -- 卖点数组
structure text, -- 故事结构选型(三幕/故事圈/雪花)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- 人物(设定库 / 角色生成器产出)
CREATE TABLE characters (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name text NOT NULL,
role text, -- 定位: 主角/CP/对手/导师/工具人
traits jsonb, -- 性格(核心-表层-阴影/大五)
appearance text,
motive text,
backstory text, -- 背景故事
arc jsonb, -- 人物弧光: 起点→转变→终点
speech_tics jsonb, -- 口癖/语言风格
tags jsonb DEFAULT '[]', -- 人设标签/萌点
relations jsonb DEFAULT '[]', -- 关系网边: [{target, type}]
first_chapter int,
latest_state text, -- 最新状态(随验收更新)
created_at timestamptz NOT NULL DEFAULT now()
);
-- 世界观实体(势力/地理/力量体系/物品)
CREATE TABLE world_entities (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type text NOT NULL, -- faction/place/power_system/item/rule
name text NOT NULL,
rules jsonb, -- 硬规则(供一致性校验引用)
first_chapter int,
latest_state text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 大纲(分卷分章 + 场景)
CREATE TABLE outline (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
volume int NOT NULL,
chapter_no int NOT NULL,
beats jsonb, -- 节拍/场景清单
foreshadow_windows jsonb DEFAULT '[]', -- 本章关联的伏笔回收窗口(可多条)
UNIQUE (project_id, chapter_no)
);
-- 章节事实摘要(append-only, 防矛盾的关键)
CREATE TABLE chapter_digests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
chapter_no int NOT NULL,
facts jsonb NOT NULL, -- 结构化事实: 谁做了什么/状态变化/新增设定
created_at timestamptz NOT NULL DEFAULT now()
);
-- 伏笔账本
CREATE TABLE foreshadow (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
code text NOT NULL, -- F-012
title text NOT NULL,
status text NOT NULL DEFAULT 'OPEN', -- OPEN/PARTIAL/CLOSED/OVERDUE
planted_at int, -- 埋设章号
content text,
expected_close_from int,
expected_close_to int,
importance text, -- 主线/支线
links jsonb DEFAULT '[]', -- 关联人物/势力
progress jsonb DEFAULT '[]', -- [{chapter, note, status}] append
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (project_id, code)
);
-- 文风指纹
CREATE TABLE style_fingerprint (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
dimensions_json jsonb NOT NULL, -- 16 维: {dim: value}
evidence_json jsonb NOT NULL, -- 每维原文证据
version int NOT NULL DEFAULT 1, -- 增量更新版本
created_at timestamptz NOT NULL DEFAULT now()
);
-- 四级规则
CREATE TABLE rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid REFERENCES projects(id) ON DELETE CASCADE, -- global 级可为 NULL
level text NOT NULL, -- global/genre/style/project
content text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 章节正文(带版本, 支持改稿回溯)
CREATE TABLE chapters (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
volume int NOT NULL,
chapter_no int NOT NULL,
content text,
status text NOT NULL DEFAULT 'draft', -- draft/accepted
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (project_id, chapter_no, version)
);
-- 审稿留痕(四审报告 + 裁决; 支撑 UX 审稿历史; append 每次审稿)
CREATE TABLE chapter_reviews (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
chapter_no int NOT NULL,
chapter_version int, -- 审的是哪个草稿版本
conflicts jsonb DEFAULT '[]', -- continuity 冲突清单
foreshadow_sug jsonb DEFAULT '[]', -- 新埋/回收建议
style jsonb, -- 漂移段 + 评分
pace jsonb, -- 注水/钩子/节拍图
health_score int, -- 0-100
decisions jsonb, -- 作者裁决(采纳/忽略/回炉)留痕
created_at timestamptz NOT NULL DEFAULT now()
);
-- 时间线(P2; MVP 先由 chapter_digests 推导)
CREATE TABLE timeline (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
event text NOT NULL,
chapter_no int,
story_time text, -- 故事内时间(可模糊)
created_at timestamptz NOT NULL DEFAULT now()
);
-- 设定讨论决议留痕(P2)
CREATE TABLE decisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
topic text NOT NULL,
decision text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
```
**运营/系统表(非创作数据)**
```sql
-- 用户(多租户主体; owner_id 外键指向此)
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL,
display_name text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 提供商凭据(各家 API Key, 加密存储; 按用户/作品)
CREATE TABLE provider_credentials (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id uuid REFERENCES projects(id) ON DELETE CASCADE, -- NULL=用户级默认
provider text NOT NULL, -- anthropic/deepseek/kimi/openai/...
api_key_enc bytea NOT NULL, -- 加密密文(KMS/加密列), 永不回显
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (owner_id, project_id, provider)
);
-- 档位路由配置(全局默认在 config; 作品级覆盖落库)
CREATE TABLE tier_routing (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid REFERENCES projects(id) ON DELETE CASCADE, -- NULL=用户/全局
tier text NOT NULL, -- writer/analyst/light
provider text NOT NULL,
model text NOT NULL,
fallback jsonb DEFAULT '[]', -- [{provider,model}] 回退链
UNIQUE (project_id, tier)
);
-- 用量记账(按 provider+model 计费, 支持多币种)
CREATE TABLE usage_ledger (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES users(id),
project_id uuid REFERENCES projects(id),
provider text NOT NULL,
model text NOT NULL,
input_tokens int NOT NULL,
output_tokens int NOT NULL,
cache_read int DEFAULT 0,
cost_minor bigint NOT NULL, -- 最小货币单位
currency text NOT NULL, -- USD/CNY...(混币种)
created_at timestamptz NOT NULL DEFAULT now()
);
-- 技能 registry(内置/自定义/社区; 对齐 PRODUCT_SPEC §5.5)
CREATE TABLE skills (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scope text NOT NULL, -- builtin/custom/community
owner_id uuid REFERENCES users(id), -- custom 时归属
name text NOT NULL,
description text,
tier text NOT NULL, -- 档位 或 provider:model
system_prompt text NOT NULL,
input_schema jsonb,
output_schema jsonb,
reads jsonb DEFAULT '[]', -- 声明式表读权限
writes jsonb DEFAULT '[]', -- 声明式表写权限
genre text,
examples jsonb DEFAULT '[]',
created_at timestamptz NOT NULL DEFAULT now()
);
-- 异步长任务状态(BackgroundTasks + 轮询; §7.4)
CREATE TABLE jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
kind text NOT NULL, -- style_learn / full_scan / batch_chars
status text NOT NULL DEFAULT 'queued', -- queued/running/done/failed
progress int DEFAULT 0, -- 0-100
result jsonb,
error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
```
### 3.2 索引设计
```sql
-- 租户 + 高频过滤
CREATE INDEX idx_characters_proj ON characters(project_id);
CREATE INDEX idx_world_proj ON world_entities(project_id);
CREATE INDEX idx_digests_proj_ch ON chapter_digests(project_id, chapter_no);
CREATE INDEX idx_chapters_proj_ch ON chapters(project_id, chapter_no);
CREATE INDEX idx_foreshadow_proj_status ON foreshadow(project_id, status); -- 看板/到期扫描
-- 可选: 实体按名匹配(确定性检索的 FTS 兜底, §3.4)
CREATE INDEX idx_char_name_trgm ON characters USING gin (name gin_trgm_ops);
CREATE INDEX idx_world_name_trgm ON world_entities USING gin (name gin_trgm_ops);
-- (P2 引入向量时再加 embedding 列 + HNSW 索引)
```
### 3.3 不可变与版本化策略
| 数据 | 策略 | 说明 |
|---|---|---|
| 章节摘要 `chapter_digests` | **append-only** | 每章一行,从不改;历史可回溯"第 X 章发生了什么" |
| 伏笔进展 `foreshadow.progress` | **数组 append** | 状态字段可变,但进展只追加 |
| 章节正文 `chapters` | **多版本行** | 改稿写新 version 行,旧版保留;`(project_id, chapter_no, version)` 唯一 |
| 文风指纹 | **版本号递增** | 增量补样本生成新 version |
| 设定(人物/世界观) | **就地更新 + latest_state** | 冲突不静默覆盖,标 `[CONFLICT]` 待裁决;重大变更可在 `decisions` 留痕 |
> 不可变原则呼应用户全局编码规范(不就地覆盖、保留历史)。验收写回为**单事务**§5.5),保证摘要/伏笔/状态一致落库或全回滚。
### 3.4 确定性记忆选择(无向量)
原型用**确定性选择**取代向量检索——"本章有谁"大纲已点名,设定集也不大,无需语义相似度去猜,且完全可调试。
- **选择来源(并集去重)**
1. **显式引用**——本章大纲 `beats` 点名的人物/世界观实体(最精准)。
2. **主角常驻**——`role` 为主角/核心的角色永远纳入。
3. **近况实体**——近 N 章 `chapter_digests.facts` 里出现过的实体N 可配,默认 35
4. **(可选)按名匹配**——正文/大纲提到的实体名,用 Postgres `pg_trgm`/全文匹配兜底§3.2 trgm 索引)。
5. 命中本章 `outline.foreshadow_windows` 的伏笔。
- **渲染为卡片**:每个选中实体行渲染成一张"角色/设定卡"文本块(喂给 AI 的"MD"由行生成,非手管文件)。
- **拼装**:选中卡 + latest_state → "易变状态块",置于 prompt 缓存断点**之后**§4.6)。
- **目的**:只注入相关设定(控成本)+ 覆盖关键实体(抗上下文衰减),对齐 PRODUCT_SPEC §3.5。
- **已知局限(覆盖盲区)**:若本章涉及一个**既未在 beats 点名、又久未出场(不在近 N 章)**的实体(如回收 50 章前的伏笔、重提冷门角色),确定性选择会**漏注入**→ AI 可能写出与旧设定冲突的内容——这正是产品要防的失败。缓解:①命中 `outline.foreshadow_windows` 的伏笔关联实体强制纳入;②对正文/大纲做按名 `pg_trgm` 匹配兜底;③**允许作者在工作台手动 pin 实体**进本章注入。这是砍向量换简单的代价向量检索P2能从语义上补这个盲区。
- **P2 升级位**:设定集膨胀或盲区频发时,在 `select_relevant_entities()` 同一接口后接入**向量检索**(加 `embedding` 列 + HNSW + 嵌入服务),上层无感。
### 3.5 数据访问层Repository
- 每张表一个 Repository`CharacterRepo`/`ForeshadowRepo`…),封装 SQL**统一强制 `project_id` 过滤**(数据隔离;多租户化时此层再加 `owner_id` 校验)。
- `core` 依赖 Repository **接口**而非实现(依赖倒置),测试时注入内存实现。
- 迁移:**Alembic** 版本化迁移CI 校验 schema 与 SQLAlchemy 模型一致。
---
## 4. LLM 网关架构 ← PRODUCT_SPEC §3.3 / §3.4 / §3.5
产品意图不绑定单一厂商Agent 只声明**能力档位**,网关映射到具体 provider+model并屏蔽各家能力差异。
### 4.1 统一内部接口契约
网关对上层(编排器/Agent暴露单一接口屏蔽各家差异
```python
class Block(BaseModel):
text: str
cache: bool = False # 稳定块可标缓存断点
class LlmRequest(BaseModel):
tier: Literal["writer", "analyst", "light"] # 档位(或显式 provider:model 锁定)
system: list[Block] = [] # 稳定块在前
input: str | list[Block] # 易变内容(断点之后)
stream: bool = False
output_schema: type[BaseModel] | None = None # 需结构化输出时(Pydantic 模型/instructor)
thinking: bool = False # 是否启用思考/推理(有则启用)
max_tokens: int | None = None
scope: "Scope" # {project_id; user_id 原型可固定}
class Usage(BaseModel):
provider: str; model: str
input_tokens: int; output_tokens: int
cache_read_tokens: int = 0
cost_minor: int; currency: str # 混币种: USD/CNY…
class LlmResponse(BaseModel):
text: str
parsed: BaseModel | None = None # output_schema 命中时的结构化结果
usage: Usage
served_by: "ServedBy" # {provider; model; fell_back}
```
- 流式:`stream=True` 返回 `AsyncIterator[Delta]`,归一各家 SSE 事件为统一 `Delta`
- 上层永远不碰具体厂商字段——换厂商对 Agent 透明。
### 4.2 适配器设计
```
LlmRequest(统一)
┌────────▼─────────┐
│ 网关核心 │ 路由→能力协商→(缓存)→调用→(回退)→记账
└────────┬─────────┘
│ 归一化请求
┌─────────────┼──────────────┐
▼ ▼ ▼
OpenAI兼容适配器 Anthropic适配器 Gemini适配器
(DeepSeek/Kimi/ (Messages API (generateContent)
Qwen/GLM/OpenAI) 形态)
```
- **适配器职责**:把 `LlmRequest` 翻译成目标家 API 请求;把响应/流/usage 翻译回统一形态;声明**能力矩阵**(见 4.4)。
- **OpenAI 兼容适配器一套覆盖多家**DeepSeek/Kimi/Qwen/GLM/OpenAI 仅 baseURL + model + key 不同。
- Anthropic、Gemini 各一适配器(请求形态不同)。
- 新增厂商 = 新增一个适配器 + 在 `config` 注册,零侵入上层。
### 4.3 档位路由与三级配置解析
```
解析优先级(高→低):单 Agent 锁定 > 作品级覆盖 > 全局默认
tier ──▶ resolve(scope) ──▶ { provider, model, fallback[] }
```
- 配置来源:`config`(全局默认)+ 作品设置DB+ Skill 内 `tier/provider:model`§5.6)。
- 解析结果含**主模型 + 回退链**UX §6.10 的"主/回退")。
- 切换厂商只改配置,不改 Agent 代码(对齐 ADR-3
### 4.4 能力协商与降级
各家能力参差,网关维护**能力矩阵**并优雅降级:
| 能力 | 支持时 | 不支持时(降级) |
|---|---|---|
| 结构化输出(JSON Schema) | 走原生 | JSON 提示 + Pydantic/instructor 校验 + 重试(**上限 2-3 次**,超限标"该审未完成"不死循环) |
| 前缀缓存 | 标记缓存断点 | 跳过缓存(不影响正确性,仅成本) |
| 思考/推理 | 启用 | 普通生成 |
| 工具调用 | 原生 | 本系统四审/生成均用结构化输出实现,不强依赖工具调用 |
- 能力矩阵由适配器声明(`capabilities()`),网关据此决定调用形态。
- 降级对上层透明,仅在 `usage`/日志标注实际行为。
- **轻量档可靠性风险**最便宜模型Qwen-turbo/GLM-flash 类JSON 结构化输出不稳,易触发重试。故 pace/style 这类轻量档审稿要用上面的重试上限兜底;若某 provider 在轻量档反复失败,运营可在档位路由把它换成更稳的模型(仍属轻量档)。
### 4.5 故障回退与重试
```
调用主模型
├─ 成功 ────────────────────────▶ 返回(标 served_by.fell_back=false)
├─ 限流(429)/超时/5xx ─▶ 指数退避重试(SDK/自实现, 上限 R 次)
│ └─ 仍失败 ─▶ 切回退链下一个 provider
└─ 内容策略拒绝 ─────────▶ 按策略可切回退或上抛(让作者知情)
回退链耗尽 ─▶ 抛 LlmUnavailable(上层降级: 提示作者换档位/稍后重试; 不丢正文)
```
- **中途失败**:流式写章已吐部分 token 后回退耗尽 → 已生成部分由自动保存留在 `chapters(draft)`LangGraph write 节点报错使图停在 write 前的 checkpoint前端提示"生成中断,可重试或基于已存部分续写",不丢稿。
- **熔断**:某 provider 连续失败超阈值,短时熔断、直接走回退,避免雪崩。
- **缓存隔离**:回退到不同 provider 时缓存失效(缓存按 `provider+model` 维度§4.6)。
- 重试只对幂等的生成/校验;写库副作用在编排层、不在网关。
### 4.6 缓存抽象(稳定前缀)
- **原则跨厂商通用**`system` 中的稳定内核(世界观硬规则 + 文风指纹 + 少量真正定型的角色)置于缓存断点前;易变(活跃角色的 `latest_state` + 本章大纲)置于断点后。
- **务实预期**:真正稳定的多是世界观硬规则 + 文风指纹;角色 `latest_state` 每次出场验收都变、新角色随时加入,所以"稳定内核"会随连载缩水、失效更频繁。**"省约 90%" 仅在稳定内核占比高时成立**不是通用承诺缓存收益应按命中率实测评估§9.3)。
- **机制按厂商封装**Claude 显式 `cache_control`OpenAI/DeepSeek 自动前缀缓存;不支持者跳过。
- **失效面**:缓存键含 `provider+model`;上游稳定块任一字节变更即失效(故组装时排序序列化、不混入时间戳/UUID
- 命中指标回传 `usage.cache_read_tokens`供观测§9.3)。
### 4.7 凭据管理与隔离
- 各提供商 API Key 加密存储KMS 或 DB 加密列),**前端永不回显明文**UX §6.10)。
- 作用域:平台级默认 Key可选+ 用户/作品级自带 Key解析时按 scope 选取。
- `测试连接`:发一个最小探测请求验证 Key 有效 + 拉取能力矩阵。
### 4.8 用量记账
- 每次调用落 `usage_ledger`provider/model/in/out/cache_read/cost`owner_id+project_id+provider+model+day` 聚合。
- 成本换算表随 provider 配置维护单价随厂商而异§3.3)。
- 供 UX §6.10 月度概览 + 按档位/作品下钻。
---
## 5. Agent 编排引擎 ← PRODUCT_SPEC §3.6 / §5
产品意图:单一职责 Agent + 确定性编排Agent 经记忆库交换、不直连;四审只读、产出经验收 gate。
### 5.1 Agent / Skill 声明式抽象
内置 Agent 与用户 Skill 同构——都是一份**声明**,由编排器加载后经网关执行:
```python
class AgentSpec(BaseModel):
name: str # worldbuilder / writer / continuity ...
tier: Tier # 能力档位(网关解析 provider+model)
system_prompt: str # 角色与约束
input_schema: type[BaseModel] # 入参契约
output_schema: type[BaseModel] | None # 结构化产出契约(网关保证; writer 为 None=纯文本)
reads: list[TableName] # 声明式表读权限
writes: list[TableName] # 声明式表写权限(经验收才生效)
genre: str | None = None # 题材适用(Skill 用)
```
- 内置 8 Agent 即 `scope=builtin` 的 AgentSpec用户 Skill 为 `custom/community`§5.6)。
- `reads/writes` 是**契约**:运行时强制,越权拒绝(安全见 §5.6 / §9.2)。
### 5.2 编排器LangGraph 图)
编排器是一张 **LangGraph 状态图**(非自治 agent loop节点固定、边确定用其**并行**跑四审、用 **interruptHITL+ checkpoint** 表达"写章→作者裁决→验收"的跨请求暂停-恢复。
```python
# 状态: { project_id, chapter_no, ctx, draft, reviews, decisions }
g = StateGraph(ChapterState)
g.add_node("assemble", lambda s: memory.assemble(s.project_id, s.chapter_no)) # §5.3
g.add_node("write", lambda s: gateway.run(writer_spec, s.ctx, stream=True)) # 纯函数产草稿
# 四审为并行分支(无依赖), 汇入 collect
for spec in (continuity_spec, foreshadow_spec, style_spec, pace_spec):
g.add_node(spec.name, make_review_node(spec)) # 只读, 结构化输出
g.add_node("collect", collect_reviews) # 汇总四审 → 落 chapter_reviews 表(留痕)
g.add_edge("assemble", "write")
for spec in REVIEW_SPECS: # write ─並行→ 四审
g.add_edge("write", spec.name); g.add_edge(spec.name, "collect")
# 验收 = HITL 中断: 图在此 interrupt; 审稿结果已落 chapter_reviews 表, 前端从表读取并裁决;
# 作者 POST /accept 携 decisions(+ 可能改过的终稿) 后, 从 checkpoint 恢复, 进 accept 节点
g.add_node("accept", commit_accept) # 单事务(含「从终稿提炼 digest」): 见 §5.5
graph = g.compile(checkpointer=PostgresSaver(...), interrupt_before=["accept"])
```
- **确定性**:图固定、可单测、可回放(给定输入 + mock 网关输出确定checkpoint 落 Postgres
- **并行**:四审为并行分支汇入 collect任一失败不阻塞其余报告标"该审未完成"。
- **HITL 恢复 + 真相源边界**:用 `interrupt_before=["accept"]` + checkpointer 支持"写章请求返回、验收请求恢复"的跨请求流程。**正文以 `chapters` 表、审稿结果以 `chapter_reviews` 表为权威真相源checkpoint 只存"图走到哪 + 待裁决句柄",不作正文/审稿的真相源**——恢复时按 `chapter_no` 从领域表重读,避免双真相源(对齐 ADR-2
- **编排 vs Agent**图做控制流与事务边界Agent 节点只做单次认知任务、经记忆库交换(不直连)。
### 5.3 记忆注入与 prompt 组装
```python
def assemble(project_id, chapter_no):
outline_row = outline_repo.get(project_id, chapter_no)
entities = select_relevant_entities(project_id, outline_row) # 确定性, §3.4
# = 显式点名 主角常驻 近况实体 (可选)按名匹配
forewin = foreshadow_repo.windows_for(chapter_no)
digests = digest_repo.recent(project_id, k) # 近况摘要
fingerprint = style_repo.latest(project_id)
rules = rules_repo.effective(project_id) # 四级合并
stable_core = serialize_sorted(world_hard_rules + settled_chars + fingerprint + rules)
volatile = serialize_sorted(render_cards(entities) + forewin + digests + outline_row.beats)
return LlmRequest(system=[Block(text=stable_core, cache=True)], input=volatile)
```
- **缓存断点**stableCore 标 `cache:true`断点前volatile 在后§4.6)。
- **确定性序列化**:排序 + 无时间戳/UUID保证缓存字节稳定。
- 四级规则合并:`global → genre → style → project`,越具体越优先。
### 5.4 8 个 Agent 的 I/O 契约(对齐 §5.2 花名册)
| Agent | 档位 | reads | writes(经验收) | 输出 schema 要点 |
|---|---|---|---|---|
| worldbuilder | 写手 | projects | world_entities | `{entities:[{type,name,rules}]}` 硬规则显式 |
| character-gen | 写手 | world_entities, characters | characters | `{cards:[{name,role,traits,backstory,arc,speech_tics,tags,relations}]}` |
| outliner | 分析 | projects, foreshadow, characters, world_entities | outline | `{chapters:[{no,beats,foreshadow_windows}]}` |
| writer | 写手 | (注入) | —(产草稿,持久化见下注) | 纯文本流(stream) |
| continuity | 分析 | chapter_digests, characters, world_entities | —(只读) | `{conflicts:[{type,where,refs,suggestion}]}`仅冲突digest 在验收时从终稿另提,见下注) |
| foreshadow-analyst | 分析 | foreshadow | —(只读) | `{planted:[...], resolved:[...]}` 建议 |
| style-auditor | 轻量(打分)/分析(提取) | style_fingerprint | style_fingerprint(仅学文风提取) | 提取`{dims,evidence}` / 打分`{segments:[{idx,score}]}` |
| pace-checker | 轻量 | rules(genre) | —(只读) | `{water:[...], hook:bool, beat_map:[...]}` |
- 所有非 writer 的 Agent **结构化输出**网关保证§4.4),便于程序消费与就地裁决。
- **writer 不直接写库**:产草稿(stream)→自动保存落 `chapters(draft)`(确定性代码,即时)→验收晋升为 `accepted` 新 version§5.5)。四审 `writes` 为空(只读)。
- **style-auditor 的写入是例外**:发生在**学文风**流程(写 `style_fingerprint`),不在写章流水线;写章时它只读指纹做漂移打分。
- **digest 从终稿提取,不从审稿草稿**:审稿时 continuity 只出冲突;作者裁决/改稿后,验收事务里用**终稿**另跑一次轻量提炼得 `digest` 再追加 `chapter_digests`§5.5/§6.1)——否则改稿前的草稿事实会污染真相源。
### 5.5 验收与状态写回(事务)
- 验收是**确定性代码**,非 Agent。把作者裁决后的变更**单事务**落库:
1. 章节晋升 `accepted` 新 version
2. **从终稿提炼 `digest`**(轻量 LLM 调用,输入是最终验收文本而非审稿草稿)→ 追加 `chapter_digests`
3. 应用伏笔状态变更/新登记、更新人物 latest_state、可选加规则
4. 写入本次裁决留痕(`chapter_reviews.decisions`)。
- 事务保证一致:全成功或全回滚,杜绝"摘要更了但伏笔没更"的半态。
- 写回后触发伏笔到期扫描§6.2)、看板/仪表盘缓存失效。
### 5.6 技能系统运行时Skill loader + 沙箱)
- **加载**:从 `skills` registry 读 AgentSpecbuiltin/custom/community`tier` 经网关执行(复用 §5.1 机制)。
- **表权限强制(执行点说明)**:这**不是进程级沙箱**——Skill 是声明式 prompt本身不执行代码。强制点在**编排/写库层**:① 注入时只把声明 `reads` 的表数据喂给该 skill② 验收/写库代码只把该 skill 产出**应用到其声明的 `writes` 表**(白名单),越权的产出字段丢弃并审计。即"声明式权限 + apply 层白名单",而非隔离运行时。
- **纯声明式**Skill 不含可执行代码,仅 prompt + schema + 权限,杜绝任意代码执行。
- **写库仍经验收 gate**:自定义 Skill 产出同样要过作者验收/四审才入库,不开后门。
- 安全细节见 §9.2。
---
## 6. 核心模块详细设计 ← PRODUCT_SPEC §4
### 6.1 长篇一致性
- **摘要提炼**:验收时从**终稿**(作者裁决/改稿后的最终文本,非审稿草稿)提炼 `digest`(结构化事实),事务追加 `chapter_digests`——避免改稿前的草稿事实污染真相源。
- **实体状态**:人物/世界观维护 `latest_state`;验收按裁决更新(带 `[CONFLICT]` 的不自动覆盖)。
- **冲突检测**:审稿时 continuity 比对草稿 vs近况摘要 + 人物卡 + 世界观硬规则),产出结构化冲突清单:
```
conflict = { type: 性格漂移|能力不符|设定违例|地理矛盾|时间线倒错,
where: 本章定位, refs: [冲突来源章/设定], suggestion: 改法 }
```
- **裁决流**:前端就地高亮 + 报告条目 → 作者选 `采纳改法 / 忽略 / 手改`;忽略可沉淀为规则。**未决冲突不允许验收**UX §9 状态)。
- **校验清单**:参考 *Lost in Stories 2026* 分类(人物/设定/时间三类)。
- 时间线MVP 由 `chapter_digests` 推导P2 落 `timeline` 表加速。
### 6.2 伏笔账本
**状态机**
```
登记 首次照应 完成回收
─────▶ OPEN ──────▶ PARTIAL ─────────▶ CLOSED
│ │
└─────────────┴──▶ OVERDUE (章号 > expected_close_to 且未 CLOSED)
(到期扫描置位; 回收后可转 CLOSED)
```
- **到期扫描**:验收后触发(确定性代码,非 Agent`current_ch > expected_close_to AND status≠CLOSED → OVERDUE`,写回并在看板/报告提醒。
- **新埋/回收建议**:审稿时 foreshadow-analyst 检测本章新埋线 / 疑似回收 → 建议,作者确认后登记/改状态。
- **依赖图/窗口**`outline.foreshadow_windows` 关联章与伏笔;排大纲时提示接近回收窗口的伏笔。
- **看板**:按 `(project_id, status)` 索引查询,四泳道 + OVERDUE 强调UX §6.8)。
### 6.3 文风模仿
- **提取(一次性,分析档)**35 样本≥5 万字)→ 16 维指纹9 通用 + 7 中文),**每维附原文证据**,存 `style_fingerprint`(版本化、可增量合并)。
- **生成约束**:写章注入指纹(缓存稳定块)。
- **漂移打分(高频,轻量档)**:每段对照指纹打相似度分;低于阈值标漂移段。
- **回炉**:仅重写漂移段、保留上下文,新旧 diffUX §8.3。阈值可配§10 风险:需校准)。
### 6.4 节奏引擎
- **模板 DSL**(存 `rules` genre 级):黄金三章、章末钩子、爽点密度(每 N 千字一拍)、情绪曲线。
- **检测pace-checker轻量档**:注水段(信息密度过低/重复铺陈)、章末钩子有无、爽点节拍图、情绪曲线。
- **产出**`{water:[段], hook:bool, beat_map:[...]}` → 报告以"爽点节拍图 ▁▃▅"可视化UX §6.4)。
- 这是对英文工具的差异化(中文网文模板)。
### 6.5 角色生成器
- **单/批量**:输入一句话需求 + 数量 + 定位 → character-gen写手档产出结构化角色卡。
- **防雷同(批量)**一次性生成一组时prompt 注入"已生成卡 + 已有角色",要求差异化定位/性格;可对产出做相似度去重复核。
- **一致性校验接入**:入库前由**编排器**追加一道 continuity 检查(非 character-gen 直接互调,符合 §5.4 数据流),确认不与世界观/力量体系冲突。
- **入库**:过校验 → 写 `characters`角色行即注入单元§3.4 渲染成卡片)→ 进入后续确定性选择注入。
---
## 7. API 设计 ← PRODUCT_SPEC §6
### 7.1 REST 约定与错误信封
- **作用域嵌套**:章节端点统一 `/projects/:id/chapters/:no/...`(修正 PRODUCT_SPEC §6 中 `:no` 跨项目不唯一的 nit见文末发现
- **鉴权****原型单用户**(无登录/归属校验);多租户 + 按 `owner_id` 校验为后续§9.1)。
- **统一成功**`200/201 { data, meta? }`。
- **统一错误信封**
```json
{ "error": { "code": "CONFLICT_UNRESOLVED", "message": "...", "details": {} } }
```
常见码:`NOT_FOUND / VALIDATION / CONFLICT_UNRESOLVED(未决冲突禁验收) / LLM_UNAVAILABLE(回退耗尽) / RATE_LIMITED`(多租户上线后补 `UNAUTHORIZED/FORBIDDEN`)。
### 7.2 端点清单(对齐 §6 + 设置/伏笔/看板)
| Method | Path | 请求 | 响应 | 码 |
|---|---|---|---|---|
| POST | `/projects` | 立项向导各字段 | project | 201 |
| GET | `/projects` | — | 作品列表(含待办徽标) | 200 |
| GET | `/projects/:id` | — | 作品详情 | 200 |
| POST | `/projects/:id/world/generate` | `{需求}` | world_entities(预览) | 200 |
| POST | `/projects/:id/characters/generate` | `{需求, count?, role?}` | 角色卡(预览,待入库) | 200 |
| POST | `/projects/:id/characters` | 角色卡 | 入库结果 | 201 |
| POST | `/projects/:id/style` | 样本(`mode=update?`) | `{job_id}`(异步见 7.4) | 202 |
| GET | `/jobs/:id` | — | 任务状态/进度/结果 | 200 |
| POST | `/projects/:id/outline` | `{范围}` | 大纲(含伏笔窗口) | 200 |
| POST | `/projects/:id/chapters/:no/draft` | `{}` | **SSE 流**草稿 | 200(stream) |
| PUT | `/projects/:id/chapters/:no/draft` | `{text}` | 自动保存草稿(幂等,写 `chapters.draft`,断流兜底) | 200 |
| POST | `/projects/:id/chapters/:no/review` | `{draft}` | **SSE 流**四审报告 | 200(stream) |
| POST | `/projects/:id/chapters/:no/accept` | `{裁决清单}` | 写回结果(将更新清单) | 200 |
| GET | `/projects/:id/chapters/:no/reviews` | — | 审稿历史(`chapter_reviews`) | 200 |
| POST | `/projects/:id/chapters/:no/refine` | `{段落, 指令}` | 回炉重写段(diff) | 200 |
| GET | `/projects/:id/foreshadow` | `?status=` | 伏笔看板数据 | 200 |
| POST | `/projects/:id/rules` | `{level, content}` | rule | 201 |
| GET/PUT | `/settings/providers` | 凭据/档位配置 | 配置(Key 脱敏) | 200 |
| POST | `/settings/providers/test` | `{provider}` | 连接+能力矩阵 | 200 |
### 7.3 流式接口SSE
- `draft` / `review` 用 **Server-Sent Events**:网关流式 `Delta` → 归一 SSE 事件 → 前端打字机渲染。
- 事件类型:`token`(正文增量)、`section`(四审分项开始/完成)、`conflict`(冲突命中)、`done`、`error`。
- 断流可重连:前端按已收 token 续接;正文自动保存兜底(不丢稿)。
### 7.4 异步长任务BackgroundTasks + jobs 表)
- **长任务**:学文风(解析数万字样本)、全书一致性回归扫描、批量群像 → **FastAPI BackgroundTasks** 在同进程后台跑,先写 `jobs` 行返回 `{job_id}`202前端轮询 `GET /jobs/:id` 取进度/结果。
- 幂等以 `job_id` 去重;失败置 `jobs.status=failed` 可重试。
- **持久性局限 + 缓解**BackgroundTasks 在进程内跑,**进程重启/部署会丢任务**、`jobs.status` 卡在 `running`。原型缓解:**启动时把所有 `running` 僵尸 job 标 `failed`**,让用户看到失败并重试(而非进度条永转)。真正的持久重试/跨进程恢复属规模化项——换 arq/Celery接口不变
- **原型不上专门队列**。
- 写章/审稿**不走后台**(要即时流式反馈),走 SSE 同步流。
---
## 8. 前端架构 ← UX_SPEC
### 8.1 应用结构Next.js App Router前端独立于后端
- 路由对齐 UX §3.1 导航:`app/(dashboard)` 作品库;`app/projects/[id]/{write,outline,codex,foreshadow,review,style,rules}``app/settings/providers`。
- **Server Components** 取数(列表/看板/设定库只读视图);**Client Components** 承载编辑器、流式、交互。
- **调用独立 FastAPI 后端**§7 端点),经 **OpenAPI 生成的 TS 客户端**`app/lib/api`SSE 用 `EventSource`/`fetch` 流读。无 Next API Routes 业务逻辑(仅可留 BFF 代理/鉴权透传)。
### 8.2 状态管理
| 状态类别 | 方案 |
|---|---|
| 服务端数据(列表/设定/看板) | React Query / SWR缓存 + 失效) |
| 编辑器本地态(正文/光标/未保存) | 局部 storeZustand 或 RSC + local |
| 流式增量(draft/review) | SSE 订阅 → 增量 reducer |
| 乐观更新(验收/裁决) | 先改 UI、失败回滚 + Toast |
### 8.3 编辑器与流式渲染
- 正文编辑器:宋体 / 720px / 行高 1.9UX §2.3contentEditable 或 ProseMirror/TipTap富文本 + 锚点标注)。
- **流式打字机**SSE `token` 逐字淡入 + 朱砂光标;尊重 `prefers-reduced-motion`(关动效)。
- **冲突标注**`conflict` 事件 → 正文对应区间挂朱砂波浪线 + 悬浮卡 + 报告联动跳转。
- **自动保存**debounce 持续保存草稿(写 `chapters` 当前 version显示"保存于 hh:mm"。
### 8.4 设计 token 落地
- UX §2 的色板/字体/间距 → CSS 变量(`--paper`, `--ink`, `--vermilion` …)+ Tailwind theme。
- 纸感主题集中在 `:root`;预留 `[data-theme="night"]` 夜读模式扩展位UX §2.1 备注)。
- 组件库UX §7一一实现AppShell / Editor / ReviewCard / ForeshadowCard / TierRouter / ProviderRow …
---
## 9. 横切关注点 ← PRODUCT_SPEC §10 / §5.5
### 9.1 鉴权与多租户隔离(原型:单用户)
- **原型阶段**:单用户,无登录/无归属校验;`users` 表与 `projects.owner_id` 保留为接缝(可写死一个 stub 用户),暂不强制。
- **多租户化(后续)**:接入 Auth邮箱/OAuth→ 所有 Repository 强制 `project_id + owner_id` 行级隔离 → 越权返回 `FORBIDDEN` + 审计;再远期多人协作预留 `project_members`。
- 因 Repository 已统一封装表访问,多租户化时只在该层加归属校验,不动业务逻辑。
### 9.2 安全
| 面 | 措施 |
|---|---|
| 密钥 | 提供商 Key 加密存储KMS/加密列);前端永不回显;仅后端解密用于调用 |
| 用户 Skill不可信输入 | 纯声明式(无代码执行);`reads/writes` 表权限白名单强制;产出经验收 gate§5.6 |
| 提示注入 | 用户/作品文本以**数据**注入,不与系统指令混层;结构化输出 + 校验抑制越权产出Skill prompt 不得提权 |
| 输入校验 | 所有端点 Pydantic 校验(系统边界),失败 `VALIDATION` |
| 数据外发 | LLM 调用即把作品内容发往第三方——UX §6.10 标境内/境外,由作者/运营按合规取舍 |
| 注入/越权审计 | 关键操作(验收/改设定/Key 变更/Skill 越权拦截)留审计日志 |
### 9.3 可观测性
- **结构化日志**structlog请求、编排步骤、网关调用provider/model/usage/fell_back/cache_read
- **追踪**OpenTelemetry一次写章 = 一条 trace跨"检索→writer→四审→验收"span定位慢点。
- **指标**:四审耗时、缓存命中率、回退率、每作品成本;驱动 UX §6.10 成本看板与告警。
### 9.4 韧性与配置
- **韧性**:网关回退/熔断/重试§4.5SSE 断流可续;正文自动保存兜底;长任务可重试幂等。
- **配置**`config` 模块集中——提供商注册、档位默认映射、env 解析、特性开关P1/P2 功能灰度)。
---
## 10. 部署与运维 ← PRODUCT_SPEC §3.1 / §10
- **环境**dev / staging / prod 三套env 与密钥分环境管理。
- **部署拓扑**:前端 Next.js + 后端 FastAPI + Postgres**常驻服务**Fly.io/Render + 托管 PG或 Docker 自托管)——因后端有 BackgroundTasks 长任务,避免 serverless 杀长函数。后端无状态可水平扩。
- **数据库**:托管 Postgres**无需 pgvector 扩展**P2 引入向量时再开);定时备份 + PITRAlembic 迁移随发布执行、CI 校验。
- **扩展性/配额**:写章并发受 LLM 限流约束——按作品**速率配额**;网关侧聚合各 provider 限流429 退避 + 回退)。规模化再抽独立 Workerarq/Celery削峰。
- **发布**CI 跑测试 + 迁移校验 + 类型检查;蓝绿/滚动DB 迁移向后兼容(先加列后用)。
---
## 11. 测试策略
对齐团队规范≥80% 覆盖,单元/集成/E2E 三类TDD
| 层 | 范围 | 要点 |
|---|---|---|
| 单元 | 领域逻辑/状态机/纯函数 | 伏笔状态机、四级规则合并、prompt 组装确定性、档位解析 |
| 集成 | Repository + DB、LangGraph 图 + mock 网关、端点 | 验收事务原子性、HITL 恢复、SSE 流 |
| 契约 | **LLM 结构化输出 schema** | 每个 Agent 的 outputSchema 用录制样例做契约测试;适配器能力矩阵测试 |
| E2E | 关键流程 | 写一章闭环(写→审→裁决→验收)、生成群像、切换提供商 |
- **LLM mock**:网关可注入 mock provider返回固定/录制响应),使编排器/Agent 可确定性测试,不烧真实 token。
- **降级/回退测试**:模拟 429/不支持结构化输出,验证降级与回退路径。
- TDD新模块先写测试RED→GREEN→重构
---
## 12. 实施路线(架构视角) ← PRODUCT_SPEC §9
按依赖关系排架构落地顺序(与产品 M1M5 对齐):
| 里程碑 | 架构交付 | 依赖 |
|---|---|---|
| **M1** | `db`(**建全部表**+迁移,含 rules) → `llm_gateway`(单 provider 起 + 档位) → `memory`(确定性选择+组装) → `orchestrator`(写章) → API(立项/写本章/SSE) → 前端 AppShell+工作台 + **设置页(至少一个 provider)** | 无 |
| **M2** | continuity Agent + review SSE + 验收事务 + 冲突裁决 UI | M1 |
| **M3** | 伏笔状态机 + 到期扫描 + 看板 + outliner 伏笔窗口 + pace-checker | M2 |
| **M4** | 文风提取(异步任务) + style-auditor 双轨 + 回炉 | M1 网关 |
| **M5** | worldbuilder/character-gen 生成 + 多 provider 回退/降级完善 + Skill 运行时 + 规则/命令面板 | M2M4 |
> 网关的多 provider/回退/降级在 M1 先打**接口与单 provider**M5 补齐多家与韧性——避免一开始过度工程。
---
## 附录 A · ADR 关键决策记录
见 §1.2 摘要表;正式实现期每条 ADR 单独建档(背景/选项/决策/后果)。核心十条:模块化单体、库为真相源、自建网关、声明式 Agent、写章纯函数、Postgres 单库(确定性选择/无向量)、网关统一结构化输出、前端 Next + 后端 FastAPI 分离、BackgroundTasks 长任务、LangGraph 编排。
## 附录 B · ARCHITECTURE ↔ PRODUCT_SPEC 覆盖矩阵
| PRODUCT_SPEC 章节 | 对应 ARCHITECTURE |
|---|---|
| §1 问题 / §1.5 哲学 / §2 目标 | §1.1 NFR / §1.2 决策 |
| §2.5 功能总览 | §12 路线(按 P0P2 落地) |
| §3.1 技术栈 | §2 技术栈与工程结构 / §10 部署 |
| §3.2 数据模型 | §3 数据架构(完整 DDL |
| §3.3/§3.4/§3.5 网关/直连/缓存 | §4 LLM 网关架构 |
| §3.6 编排示意 | §5.2 编排器 |
| §4 四大模块 + 角色生成 | §6 核心模块详细设计 |
| §5 多 Agent | §5 Agent 编排引擎 |
| §6 接口 | §7 API 设计 |
| §7 规则积累 | §5.3 四级规则合并 + §7 rules 端点 |
| §8 差异化 | (产品论证,不在架构展开) |
| §9 实施路线 | §12 实施路线(架构视角) |
| §10 风险 | §9 横切关注点(安全/韧性/合规) |
| UX_SPEC | §8 前端架构 |
## 附录 C · 术语表
| 术语 | 含义 |
|---|---|
| 档位(tier) | 能力等级(写手/分析/轻量),网关映射到 provider+model |
| 适配器 | 把统一 LlmRequest 翻译成某厂商 API 的模块 |
| 真相源 | 数据库——Agent 间唯一交换媒介 |
| 四审 | continuity/foreshadow/style/pace 四个并行质检 Agent |
| 验收 gate | 作者裁决后的事务性写回AI 产出入库唯一入口 |
| 稳定内核 | prompt 中可缓存的稳定前缀(世界观硬规则+已定型角色+文风指纹) |
---
## 待回写规格的发现(已同步三份文档一致)
下列在架构下沉中暴露的上游缺口**已回写** PRODUCT_SPEC / UX_SPEC三份文档现已一致
1. ✅ **API 作用域**:章节端点统一为 `/projects/:id/chapters/:no/...`PRODUCT_SPEC §6 + UX §3.2 已改)。
2. ✅ **向量检索砍除**原型改确定性记忆选择§3.4),删除 `embedding` 列/HNSWPRODUCT_SPEC §3.2 已注明"向量检索为 P2届时再加 embedding 列 + 嵌入服务"。
3. ✅ **回炉端点**PRODUCT_SPEC §6 已增 `POST /projects/:id/chapters/:no/refine`(本架构 §7.2)。
4. ✅ **users 表**PRODUCT_SPEC §3.2 已补 `users` 与 `projects.owner_id`(本架构 §3.1 运营表)。

146
CLAUDE.md Normal file
View File

@@ -0,0 +1,146 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Repository status
**This is a spec-only repository at the pre-implementation stage.** There is no application code yet — only four design documents. The next step is Phase 0 / T0.1 in `DEV_PLAN.md` (scaffold the monorepo). Do not assume any build/test tooling exists until you create it.
Product: an AI-assisted **Chinese web-novel writing workflow**, delivered as a web app. The core thesis (`PRODUCT_SPEC.md §1.5`) is to treat long-form novel writing like software engineering — establish architecture (worldbuilding/characters/outline), generate against spec, test every chapter, maintain a single source of truth.
## Documentation map — read the right doc
The four specs are layered. Read in this order; each later doc refines the earlier.
| Doc | Answers | Read when |
|---|---|---|
| `PRODUCT_SPEC.md` | What & why — problem, features (+priorities P0/P1/P2), data model, endpoints, agents | Understanding scope or a feature |
| `UX_SPEC.md` | What it looks like — IA, user flows, page wireframes, components, visual tokens (纸感/warm-cream theme) | Building any UI |
| `ARCHITECTURE.md` | How to implement — full DDL, LLM gateway internals, orchestration engine, API contracts, cross-cutting concerns | Implementing backend/data/gateway |
| `DEV_PLAN.md` | In what order — Phase 05, decoupled tasks each tagged with a discipline skill (`@backend`/`@frontend`/`@db`/`@llm`/`@devops`/`@qa`) | Picking the next task |
`ARCHITECTURE.md` sections are anchored to `PRODUCT_SPEC.md` sections (`← PRODUCT_SPEC §x`). DEV_PLAN tasks reference both.
**冲突裁决**:若两份 spec 出现矛盾,以更具体/更靠后的文档为准(`ARCHITECTURE` > `UX_SPEC`/`DEV_PLAN` > `PRODUCT_SPEC`),并回写修正上游文档以消除分歧(见下文 §Conventions 的 spec 一致性纪律。具体的枚举值DDL 字段、SSE 事件、日志字段、错误码、§x.y 锚点)以 ARCHITECTURE/PRODUCT_SPEC 为唯一真源——本文件只重述跨文档、易错的**规则**,不复制会变的清单。
## Locked tech stack — do not relitigate
These were decided after extended discussion (`DEV_PLAN.md §0`). Honor them; do not reintroduce the rejected alternatives.
- **Frontend**: Next.js + TypeScript (UI only; calls the backend via an OpenAPI-generated TS client — **no hand-written shared types, no business logic in Next API routes**).
- **Backend**: Python + **FastAPI** (async, SSE).
- **Orchestration**: **LangGraph** (the write→review→accept graph).
- **LLM access**: a **thin self-built gateway** over `anthropic` + `openai`(baseURL covers DeepSeek/Kimi/Qwen/GLM) + `google-genai`. **Not** LangChain, **not** vendor CLIs/managed-agent runtimes.
- **Structured output**: Pydantic + `instructor`.
- **ORM/migrations**: SQLAlchemy 2.0 (async) + Alembic.
- **Storage**: Postgres. **No pgvector** in the prototype.
- **Long tasks**: FastAPI BackgroundTasks + a `jobs` table. **No dedicated queue.**
**Prototype scope — explicitly deferred (do not build unless asked):** multi-tenancy / auth (prototype is **single-user**, `users`/`owner_id` are stubs), vector retrieval (P2), a real task queue.
## Architectural invariants — easy to get wrong, must hold
These span multiple docs and were the hard-won conclusions of design review. Violating them breaks the product's core value (long-form consistency) or its provider-neutrality.
1. **Memory (the DB) is the single source of truth.** Agents communicate *only* through DB tables, never by calling each other directly (`ARCHITECTURE.md §5.4`).
2. **Agents declare a capability `tier`** (`writer`/`analyst`/`light`), never a concrete model. The gateway maps tier→provider+model per config. Never hardcode a provider or SDK in agent/orchestrator code (`ARCHITECTURE.md §3.3/§4`).
3. **The four reviewers (continuity/foreshadow/style/pace) are READ-ONLY.** No AI output is written silently. Every write goes through the **accept transaction** (HITL gate) after the author adjudicates (`§5.5`). Unresolved conflicts must block accept (`CONFLICT_UNRESOLVED`).
4. **Chapter digest is extracted from the FINAL accepted text, not the review-time draft.** The author may edit during adjudication; extracting from the draft would pollute the truth source (`§5.4/§5.5/§6.1`).
5. **In-flight truth-source boundary**: `chapters` (text) and `chapter_reviews` (review results) are authoritative; the LangGraph checkpoint holds only control-flow position + a pending-decision handle. On resume, re-read domain tables (`§5.2`).
6. **Memory injection is deterministic selection** (explicit-named + main characters + recent N chapters), **not** vector search. Be aware of the coverage-gap mitigations (foreshadow-window entities, name-match FTS, author pin) in `§3.4`.
7. **`章 = f(outline, selected state, fingerprint)`** — writing a chapter is a pure function; all state mutation is isolated to the accept transaction.
8. **Naming contract**: backend is Python/Pydantic → **snake_case** everywhere (request/response fields, schemas). The frontend consumes these via OpenAPI codegen.
9. **Prompt caching** = stable prefix (`cache_control` breakpoint): stable core (world hard rules + fingerprint) in `system` before the breakpoint, volatile state (latest_state + outline) after it. Never put timestamps/UUIDs in the cached prefix.
## Development practices (开发模式) — non-negotiable
### TDD — test-first, always
- RED → GREEN → REFACTOR. Write the failing test **before** the implementation. Target ≥80% coverage (global rule).
- **Mock the LLM in every unit/integration test** — inject a fake gateway/provider returning fixtures. Tests must be deterministic, fast, cost-free; **never hit a real LLM API in tests/CI**.
- Scope: **Unit** (one node/function/Repository, mocked IO) → **Integration** (FastAPI endpoint + DB + mocked gateway; LangGraph flow paths) → **E2E** (write→review→accept with mock gateway).
- Run a single test: `pytest path/to/test.py::test_name -q` (backend) · `vitest run -t "name"` (frontend).
### Python
- **async-first**: FastAPI async endpoints, SQLAlchemy 2.0 async, async gateway calls. Never block the event loop (no sync DB/HTTP in async paths).
- Full type hints; `mypy` + `ruff` (lint+format) clean before commit. snake_case everywhere.
- Pydantic at every boundary (request/response, structured LLM output, config).
- Depend on Repository/gateway **interfaces**, not concrete impls, so tests inject fakes (ARCHITECTURE §3.5). Small focused functions/files; immutable updates (return new objects, per global coding-style rule).
### LangGraph (orchestrator)
- Nodes are **small deterministic units**; keep LLM nondeterminism behind the gateway so node logic is testable. Test nodes by **calling the node function directly** (no graph runtime) + test flow paths with a **mocked gateway**.
- Put `interrupt()` (the accept HITL pause) at the **start** of the node — don't duplicate logic or mutate state before interrupting.
- Use the **Postgres checkpointer** (durable/resumable across the write→accept request gap). Run `checkpointer.setup()` in **migrations/CI**, not in app runtime.
- Transient-failure retries (backoff/tenacity) live in the **gateway** (§4.5); a node surfaces a clean failure, never loops. On resume, re-read `chapters`/`chapter_reviews` from DB (checkpoint = control-flow only).
### Frontend
- TS strict; **use the OpenAPI-generated client** — never hand-write API types (regenerate on backend schema change).
- Server Components for read views; Client Components for editor/streaming/interaction.
- SSE: handle every event type in the stream contract (ARCHITECTURE §7 / `memory/contracts.md` — the authoritative event list); "stop" = abort the connection; reconnect resumes from the saved draft.
- Optimistic updates with rollback + toast on failure; respect `prefers-reduced-motion`; meet the a11y bar in `UX_SPEC.md §10`.
### Logging (埋好 log便于查错) — wire from day one
Aligns `ARCHITECTURE.md §9.3`. This is how every failure gets diagnosed.
- **Structured logs** (`structlog`; JSON outside dev). Never `print`.
- **Correlation id per request**: generate/propagate `request_id`; one "write a chapter" = one trace spanning assemble→write→4 reviews→accept. Put `request_id` on every log line **and in the error response envelope** so a user-reported error is greppable end-to-end.
- **Log every LLM call** with the full call-metadata field set — feeds the cost ledger; the authoritative field list lives in `ARCHITECTURE §4.8` (don't duplicate it here).
- **Log graph transitions** (node enter/exit, interrupt, resume) tagged with `project_id`/`chapter_no`/`request_id`.
- **Errors** logged with full context at the boundary, mapped to the error envelope (`ARCHITECTURE §7.1` is the source of truth for envelope shape + error codes). Frontend logs client errors with the same `request_id`.
- **Redact**: never log API keys; truncate/hash full prompts and manuscript text (log lengths/hashes, not the novel).
## Toolchain (Phase 0 已落地)
工具:**uv**(Python workspace4 members: `apps/api` + `packages/{shared,config,db}`) + **pnpm**(前端,`apps/web`)。Python 3.12+Node 22。
- **环境变量**`uv` 装到 `~/.local/bin`,命令前 `export PATH="$HOME/.local/bin:$PATH"`
- **依赖安装**`uv sync`(后端,仓库根);`pnpm install`(前端,`cd apps/web`)。
- **本地起服务**`docker compose up`pg + api + web。仅起库`docker compose up -d pg`。裸跑 API`uv run uvicorn ww_api.main:app --reload`
- **迁移**`uv run alembic upgrade head`;改模型后 `uv run alembic revision --autogenerate -m "..."`;漂移校验 `uv run alembic check`(需 pg 在跑)。
- **后端门禁**`uv run ruff check .` · `uv run ruff format .` · `uv run mypy packages apps` · `uv run pytest -q`。单测:`uv run pytest path::test -q`
- **前端门禁**`cd apps/web``pnpm lint` · `pnpm typecheck` · `pnpm test`vitest· `pnpm build`
- **重生成 TS 客户端**:改后端 schema 后 `cd apps/web && pnpm gen:api`(离线:`uv run python -m ww_api.export_openapi``openapi-typescript` 生成 `lib/api/schema.d.ts`)。
- **pnpm 配置**:在 `apps/web/pnpm-workspace.yaml`pnpm 11 不再读 package.json/.npmrc——含 `onlyBuiltDependencies` 白名单 + `verifyDepsBeforeRun: false`(见 `memory/gotchas.md`)。
- **CI**`.github/workflows/ci.yml`backend job 带 pg service 跑 ruff/mypy/alembic/pytestfrontend job 跑 gen:api/lint/typecheck/test
## Conventions specific to this repo
- All four specs are kept mutually consistent. If implementation reveals a spec gap, update the spec(s) too — don't let code and spec drift. (The specs already track this discipline; see ARCHITECTURE's "待回写规格的发现" pattern.)
- Docs are in Chinese; keep new docs/comments consistent with surrounding language.
## Multi-agent collaboration (多 Agent 协同)
DEV_PLAN tasks are tagged with discipline skills (`@backend`/`@frontend`/`@db`/`@llm`/`@devops`/`@qa`) so they can run in parallel. Coordination is **file-based** through two artifacts. **Read both before starting any task.**
- **`PROGRESS.md`** — the single shared task ledger + chronological log. Claim, sequence, and report here.
- **`memory/`** — durable shared knowledge across sessions/agents:
- `memory/contracts.md` — the decoupling seams (OpenAPI endpoints, Pydantic/TS schemas, gateway/orchestrator interfaces). **Contract-first**: the owning agent registers a contract here and marks it `稳定` *before* dependent agents start.
- `memory/decisions.md` — implementation decisions not covered by the specs, with rationale (append-only, one decision per entry).
- `memory/gotchas.md` — pitfalls/conventions discovered while building, so others don't repeat them (append-only).
### Directory ownership (sole-writer; others read-only)
Edit **only** files inside your skill's directories. This eliminates write conflicts. To change a file outside your scope, write a request entry in `PROGRESS.md` for the owner instead of editing it.
| Skill | Owns (sole writer) |
|---|---|
| `@db` | `packages/db/` |
| `@llm` | `packages/llm_gateway/`, `packages/agents/`, `packages/core/orchestrator/` |
| `@backend` | `apps/api/`, `packages/core/{memory,domain}/`, `packages/skills/`, `packages/shared/`, `packages/config/` |
| `@frontend` | `apps/web/` |
| `@devops` | repo-root config (`docker-compose*`, CI, `pyproject.toml`, `package.json`, Alembic config) |
| `@qa` | `tests/` (integration/E2E). Unit tests live with the owning module and are written by that module's owner. |
`packages/shared/` (Pydantic schemas) and `apps/api` OpenAPI are **contracts** — changing them requires a `memory/contracts.md` entry and a `PROGRESS.md` note so dependents re-generate the TS client / re-sync.
### Per-task workflow (every agent, every task)
1. **Read** `PROGRESS.md` + relevant `memory/` files.
2. **Check dependencies** — if an upstream task isn't `✅`, don't start; pick another or mark `⛔ blocked`.
3. **Claim** — set the task to `🔵 in-progress @<skill> <date>` in `PROGRESS.md`. If already `🔵` by someone else, skip it.
4. **Contract-first** — if your task defines a contract, register it in `memory/contracts.md` and mark `稳定` before dependents proceed. If you consume one, build against the registered contract, not assumptions.
5. **Work only in your owned directories**, one file at a time.
6. **On completion** — set `✅`, append a one-line entry to `PROGRESS.md`'s log (what changed + which contracts/files affected), and record any decision/gotcha in `memory/`.
7. **Honor the architectural invariants above** — they are not negotiable for speed.
### Memory hygiene
Append, don't rewrite history. One fact per entry. Don't duplicate what the specs or git already record — capture only what's non-obvious and useful to a sibling agent. Delete entries proven wrong.

158
DEV_PLAN.md Normal file
View File

@@ -0,0 +1,158 @@
# 网文创作工作流 · 开发计划DEV_PLAN
> 基于 [PRODUCT_SPEC.md](./PRODUCT_SPEC.md) / [UX_SPEC.md](./UX_SPEC.md) / [ARCHITECTURE.md](./ARCHITECTURE.md) 的分阶段实现计划。
> 子任务尽量**解耦**(只经契约耦合,可并行);每个任务标注所需 **expert skill** 与**文档锚点**。
---
## 0. 锁定技术栈(本计划前提)
| 维度 | 决策 |
|---|---|
| 前端 | Next.js + TypeScript纯 UIOpenAPI 生成的 TS 客户端调后端) |
| 后端 | Python + FastAPIasync, SSE, Pydantic |
| 编排 | **LangGraph**(写章→四审→验收 HITL/checkpoint/并行) |
| LLM 网关 | 薄自建:`anthropic` + `openai`(baseURL 覆盖 DeepSeek/Kimi/Qwen/GLM) + `google-genai`;缓存/降级/回退/记账自建 |
| 结构化输出 | Pydantic + `instructor` |
| ORM/迁移 | SQLAlchemy 2.0(async) + Alembic |
| 存储 | Postgres**无向量**;确定性按需注入) |
| 长任务 | FastAPI BackgroundTasks + `jobs` 表(无专门队列) |
| 多租户 | **原型不做**(单用户 stub后续再加 |
**原型不做(均为后续)**:多租户/Auth、向量检索(P2)、专门队列、全书扫描、社区市场。
---
## 1. 任务约定
格式:`Tx.y [skill] 标题` → 描述 · **依赖** · **DoD**(验收) · **锚点**(文档)。
**Skill 标签**(每任务调用对应 expert skill 编写):
| 标签 | 职责 |
|---|---|
| `@devops` | monorepo/CI/docker/部署/迁移工具链 |
| `@db` | SQLAlchemy 模型、Alembic 迁移、Repository |
| `@backend` | FastAPI 端点、记忆服务、验收事务、BackgroundTasks |
| `@llm` | LLM 网关、适配器、LangGraph 图、Agent 声明 |
| `@frontend` | Next.js 页面、组件、流式渲染、TS 客户端 |
| `@qa` | 单元/集成/E2E、mock 网关、契约测试 |
| `@docs` | 文档修订 |
**解耦原则**:任务只经**契约**耦合OpenAPI / Pydantic schema / `orchestrator`·`gateway`·`Repository` 接口)。**契约先行**——每阶段首个任务先定 schema/接口,随后 front/back/llm 并行。
---
## Phase 0 · 基建与架构栈修订
> 目标:可运行的空骨架 + 文档与锁定栈一致。
- **T0.1 [@devops] monorepo 骨架** → `apps/web`(Next/TS) + `apps/api`(FastAPI) + `packages/*`(python) + `docker-compose`(pg)。· 依赖 无 · DoD `docker compose up` 起 pg+api+web根路由各返回 200。· 锚点 ARCH §2.2/§2.3
- **T0.2 [@db] 初始 schema + 迁移** → SQLAlchemy 模型(全部 **MVP 表**9 张创作表 + `chapter_reviews` + `chapter_digests` + jobs/skills/provider_credentials/tier_routing/usage_ledger**P2 表 `timeline`/`decisions` 不建****无向量**users stub) + Alembic 初版迁移。· 依赖 T0.1 · DoD `alembic upgrade head` 一次建齐全部 MVP 表(含 `chapter_reviews`/`chapter_digests`,后续阶段不再加建表迁移,只加 Repository/约束);模型↔迁移 CI 校验通过。· 锚点 ARCH §3.1
- **T0.3 [@backend] FastAPI 骨架 + 契约基建 + 可观测性底座** → 应用入口、`config`(提供商注册/档位默认/env)、统一错误信封(**带 `request_id`**)、OpenAPI 输出、`jobs` 轮询端点 `GET /jobs/:id`**structlog 结构化日志 + 每请求 `request_id` 生成/透传中间件**(一次"写一章"= 一条贯穿 assemble→write→四审→accept 的 trace。· 依赖 T0.1 · DoD `/openapi.json` 可取;错误信封统一且含 `request_id`;日志为 JSON 且每行带 `request_id`。· 锚点 ARCH §7.1/§7.4/§9.3
- **T0.4 [@frontend] 前端骨架 + 设计 token + 客户端代码生成** → Tailwind + 纸感 CSS 变量(UX §2)、`apps/web/lib/api``/openapi.json` 生成 TS 类型的管线。· 依赖 T0.1,T0.3 · DoD 主题 token 生效;改后端 schema 后 `npm run gen:api` 同步类型。· 锚点 UX §2 / ARCH §8.4
- **T0.5 [@devops] CI** → ruff+mypy+pytest后端、eslint+tsc+vitest前端、Alembic 校验。· 依赖 T0.1 · DoD PR 全绿才可合。· 锚点 ARCH §11
- **T0.6 [@docs] 架构栈修订** → 按计划清单回写 ARCHITECTURE+PRODUCT_SPECPython/FastAPI/LangGraph/无向量/无队列/单用户/OpenAPI 契约)。· 依赖 无 · DoD 两文档无残留 pgvector/队列/Node 后端/ts 代码块;三文档栈口径一致。· 锚点 全文 ✅(已完成)
---
## Phase 1 (M1) · 写章闭环骨架
> 目标:立项 → 写一章草稿(流式)→ 自动保存。**契约先行T1.1 网关接口 + T1.4 API schema。**
- **T1.1 [@llm] LLM 网关接口 + 单 provider 适配器** → `gateway.run(LlmRequest)->LlmResponse``AsyncIterator[Delta]` 流;先实现 1 个 OpenAI 兼容适配器(如 DeepSeek档位路由读 `tier_routing`/默认usage 回传;**每次调用按 §4.8 字段集落 `usage_ledger`**(成本账本从第一天起,带 `request_id`)。· 依赖 T0.2,T0.3 · DoD 单测:给 LlmRequest 得流式 token + usagemock provider 可注入;一次调用产生一条 `usage_ledger` 记录。· 锚点 ARCH §4.14.3/§4.8/§9.3
- **T1.2 [@backend] 记忆服务 `assemble`(确定性选择)** → `select_relevant_entities`(显式+主角+近况) + `render_cards` + prompt 组装(稳定内核/易变 + 缓存断点)。· 依赖 T0.2 · DoD 单测:给定大纲/设定,输出确定且稳定块排序无时间戳。· 锚点 ARCH §3.4/§5.3
- **T1.3 [@llm] LangGraph 写章节点 + SSE** → 单节点图(write) + checkpointer(Postgres) + 流式输出归一为 SSE 事件(`token/done/error`)。· 依赖 T1.1,T1.2 · DoD 调用得流式草稿;图状态落 checkpoint。· 锚点 ARCH §5.2/§7.3
- **T1.4 [@backend] API立项 + 写章** → `POST /projects``GET /projects``GET /projects/:id``POST /projects/:id/chapters/:no/draft`(SSE)、`PUT /projects/:id/chapters/:no/draft`(章节自动保存,写 `chapters` draft见 ARCH §7.2)。· 依赖 T0.3,T1.3 · DoD 端点契约入 OpenAPI`draft` 返回 SSE 流;`PUT draft` 幂等保存当前草稿。· 锚点 ARCH §7.2
- **T1.5 [@frontend] AppShell + 作品库 + 立项向导** → 顶栏/左导航(UX §5)、作品库卡片(UX §6.1)、5 步立项向导(UX §6.2)落 `projects` 字段。· 依赖 T0.4,T1.4 · DoD 可建作品并进入工作台。· 锚点 UX §5/§6.1/§6.2
- **T1.6 [@frontend] 写作工作台(核心)** → 三栏(目录/宋体正文编辑器/本章助手)、流式打字机、本章注入透明面板、自动保存(UX §6.3/§8.3)。· 依赖 T1.4 · DoD 选章→写本章→流式出草稿→自动保存。· 锚点 UX §6.3/§8.3
- **T1.7 [@backend] 提供商凭据管理** → 加密存储 `provider_credentials``GET/PUT /settings/providers``POST /settings/providers/test`(探测+能力矩阵)。· 依赖 T0.2,T1.1 · DoD Key 脱敏返回;测试连接通。· 锚点 ARCH §4.7 / UX §6.10
- **T1.8 [@frontend] 设置页(模型与提供商)** → 档位路由 + 凭据行(脱敏/测试连接/能力徽标)(UX §6.10)。· 依赖 T0.4,T1.7 · DoD 连一家 provider 后可写章。· 锚点 UX §6.10
- **T1.9 [@qa] M1 E2E** → 立项→写一章草稿(mock 网关)→自动保存。· 依赖 T1.5T1.8 · DoD E2E 绿;网关用 mock 不烧 token。· 锚点 ARCH §11
---
## Phase 2 (M2) · 一致性 + 验收
> 目标:写→审(一致性)→裁决→验收(事务写回)。**契约先行T2.1 审稿/冲突 schema。**
- **T2.1 [@llm] continuity Agent 声明 + 结构化输出契约** → `AgentSpec`(分析档) + 输出 `{conflicts:[{type,where,refs,suggestion}]}`(仅冲突digest 改在验收时从终稿另提)。· 依赖 T1.1 · DoD 契约测试mock 响应符合 schema。· 锚点 ARCH §5.1/§5.4
- **T2.2 [@llm] LangGraph 并行审 + review SSE** → write→并行分支(先 continuity)→collect(**落 `chapter_reviews` 留痕**)review 端 SSE(`section/conflict/done`)。· 依赖 T1.3,T2.1 · DoD review 流式返回结构化冲突且入库可查。· 锚点 ARCH §5.2/§7.3
- **T2.3 [@db] 摘要/审稿留痕 Repository + 章节 version** → (表已在 T0.2 建好)`chapter_digests` 追加、`chapter_reviews` 写入、`chapters` 多 version 的 **Repository 逻辑 + 唯一约束**(不含新建表迁移)。· 依赖 T0.2 · DoD 单测append 不覆盖;`(chapter, version)` 唯一。· 锚点 ARCH §3.1/§3.3
- **T2.4 [@backend] 验收事务 + 冲突 gate** → `POST /accept`(单事务:章节晋升 version + **从终稿提炼 digest** append + 裁决留痕 `chapter_reviews.decisions` + 占位 foreshadow/char 更新);未决冲突拦截(`CONFLICT_UNRESOLVED`)LangGraph HITL 恢复(checkpoint 仅控制流,正文/审稿以领域表为准)。· 依赖 T2.2,T2.3 · DoD 事务原子(失败全回滚)digest 来自终稿非草稿;有未决冲突禁验收。· 锚点 ARCH §5.5/§7.1
- **T2.5 [@backend] review API + 历史** → `POST /projects/:id/chapters/:no/review`(SSE) + `GET .../reviews`(审稿历史)。· 依赖 T2.2 · DoD 契约入 OpenAPI。· 锚点 ARCH §7.2
- **T2.6 [@frontend] 审稿报告页 + 冲突裁决 + 验收 gate** → 审稿页(UX §6.4)、正文冲突就地波浪线/锚点、裁决(采纳/忽略/手改)、验收「本次将更新」清单,未决禁验收。· 依赖 T2.4,T2.5 · DoD 写→审→裁决→验收闭环。· 锚点 UX §6.4/§8.3
- **T2.7 [@qa] M2 E2E** → 写→审(一致性)→裁决→验收→摘要入库。· 依赖 T2.6 · DoD E2E 绿。· 锚点 ARCH §11
---
## Phase 3 (M3) · 伏笔 + 节奏
> 目标:伏笔账本/到期提醒/看板 + 节奏引擎 + 大纲。
- **T3.1 [@db] 伏笔表 + 状态机** → `foreshadow` 表 + 纯函数状态机(OPEN/PARTIAL/CLOSED/OVERDUE)。· 依赖 T0.2 · DoD 状态机单测覆盖全转移。· 锚点 ARCH §6.2 / PS §4.2
- **T3.2 [@backend] 到期扫描BackgroundTask** → 验收后触发扫描置 OVERDUE登记/状态变更接口。· 依赖 T2.4,T3.1 · DoD 章号越界自动 OVERDUE。· 锚点 ARCH §6.2
- **T3.3 [@llm] foreshadow-analyst + pace-checker 节点** → 并入四审genre 模板 DSL(存 `rules` genre 级)。· 依赖 T2.2 · DoD 四审齐全、结构化输出。· 锚点 ARCH §5.4/§6.2/§6.4
- **T3.4 [@llm] outliner Agent + 伏笔窗口** → 大纲节点;产出含 `foreshadow_windows`;接近回收窗口提示。· 依赖 T1.1 · DoD 生成分卷分章 + 窗口。· 锚点 ARCH §5.4 / PS §4.2
- **T3.5 [@backend] APIoutline + foreshadow** → `POST /outline``GET /foreshadow?status=`。· 依赖 T3.1,T3.4 · DoD 看板数据可取。· 锚点 ARCH §7.2
- **T3.6 [@frontend] 伏笔看板 + 大纲编辑器 + 节奏报告** → 四泳道看板(UX §6.8)、大纲伏笔徽标(UX §6.7)、节奏节拍图(UX §6.4)。· 依赖 T3.3,T3.5 · DoD OVERDUE 琥珀提醒;节拍图渲染。· 锚点 UX §6.7/§6.8
- **T3.7 [@qa] M3 E2E** → 埋设→进展→逾期提醒;排大纲含窗口。· 依赖 T3.6 · DoD E2E 绿。· 锚点 ARCH §11
---
## Phase 4 (M4) · 文风
> 目标:学文风(指纹) + 漂移打分/回炉。
- **T4.1 [@backend] BackgroundTasks 长任务框架** → `jobs` 写入/进度/查询;`POST /style`(202+jobId)。· 依赖 T0.3 · DoD 长任务异步跑、进度可轮询。· 锚点 ARCH §7.4
- **T4.2 [@llm] style-auditor 双轨 + 并入第四审** → 提取(分析档,带原文证据)→`style_fingerprint`;漂移打分(轻量档)**把漂移打分作为第四审并入 LangGraph review 并行分支与 review SSE**(补齐 continuity/foreshadow/pace 之外的第四审,对齐 ARCH §5.2「四审」)。· 依赖 T1.1,T2.2,T3.3 · DoD 指纹 16 维带证据段落相似度分review 图含四个并行分支、SSE 含文风漂移分项。· 锚点 ARCH §6.3/§5.2 / PS §4.3
- **T4.3 [@backend] APIstyle + refine** → `POST /style`(mode=update)、`POST /chapters/:no/refine`(回炉段)。· 依赖 T4.1,T4.2 · DoD 回炉返回新旧 diff。· 锚点 ARCH §7.2
- **T4.4 [@frontend] 文风页 + 漂移/回炉** → 样本上传+指纹+证据(UX §6.9)、漂移段标注、回炉 diff(UX §8.3)。· 依赖 T4.3 · DoD 学文风→写章漂移标红→一键回炉。· 锚点 UX §6.9/§8.3
- **T4.5 [@qa] M4 E2E** → 学文风→写章打分→回炉。· 依赖 T4.4 · DoD E2E 绿。· 锚点 ARCH §11
---
## Phase 5 (M5) · 生成 + 多 provider + 扩展
> 目标:世界观/角色生成 + 网关多 provider 韧性 + Skill/规则/命令面板。
- **T5.1 [@llm] worldbuilder + character-gen 节点** → 写手档;群像防雷同(注入已生成+已有);编排器入库前追加 continuity 校验。· 依赖 T2.1 · DoD 批量产差异化角色卡、过校验。· 锚点 ARCH §6.5 / PS §4.5
- **T5.2 [@backend] 生成/入库 API** → `POST /world/generate``/characters/generate``POST /characters`(入库)。· 依赖 T5.1 · DoD 预览→入库→进入注入。· 锚点 ARCH §7.2
- **T5.3 [@frontend] 角色生成器 + 世界观设计器 + 设定库** → 生成模态(UX §6.6)、世界观设计器、设定库 Codex(人物/世界观/时间线, UX §6.5)。· 依赖 T5.2 · DoD 一句话生成→预览→入库Codex 可管理。· 锚点 UX §6.5/§6.6
- **T5.4 [@llm] 网关多 provider 韧性** → 多适配器(Anthropic/Gemini/更多 OpenAI 兼容)、回退链 + 熔断 + 能力协商/降级(`usage_ledger` 记账已在 T1.1 落地,此处仅扩展多 provider 维度)。· 依赖 T1.1 · DoD 限流/不支持结构化输出时降级/回退;多 provider 记账分维度可查。· 锚点 ARCH §4.44.6
- **T5.5 [@backend] Skill 运行时 + 规则** → `skills` registry loader + 表权限沙箱(越权拒绝) + `POST /rules`。· 依赖 T0.2,T1.1 · DoD 自定义 skill 受表权限约束、产出经验收 gate。· 锚点 ARCH §5.6 / PS §5.5/§7
- **T5.6 [@frontend] 规则页 + 技能库 + 命令面板** → 规则管理、技能库、命令面板(⌘K)。· 依赖 T5.5 · DoD 加规则/调 skill/快捷跳转。· 锚点 UX §7
- **T5.7 [@qa] M5 E2E + 切 provider** → 生成群像入库;切换 provider 回归(降级/回退)。· 依赖 T5.3,T5.4 · DoD E2E 绿;切 provider 不破。· 锚点 ARCH §11
---
## 2. 阶段 DoD 矩阵
| 阶段 | 出口标准Definition of Done |
|---|---|
| Phase 0 | 骨架可起全表迁移CI 绿;文档与锁定栈一致 |
| M1 | 立项→写一章草稿(流式)→自动保存;连一家 provider |
| M2 | 写→审(一致性)→裁决→验收(事务);未决冲突禁验收 |
| M3 | 伏笔账本/到期提醒/看板;大纲含回收窗口;节奏报告 |
| M4 | 学文风(指纹+证据);漂移打分+回炉 |
| M5 | 世界观/群像生成入库;多 provider 回退/降级Skill 沙箱 |
## 3. 任务 ↔ 文档锚点覆盖(抽样自检)
| 功能(PS §) | 任务 | UX/ARCH 锚点 |
|---|---|---|
| 立项 §6 | T1.4/T1.5 | UX §6.2 |
| 写章 §4/§6 | T1.2/T1.3/T1.6 | ARCH §5.2/§5.3 |
| 一致性 §4.1 | T2.1/T2.4/T2.6 | ARCH §6.1 |
| 伏笔 §4.2 | T3.1/T3.2/T3.6 | ARCH §6.2 / UX §6.8 |
| 文风 §4.3 | T4.2/T4.4 | ARCH §6.3 / UX §6.9 |
| 节奏 §4.4 | T3.3/T3.6 | ARCH §6.4 |
| 角色生成 §4.5 | T5.1/T5.3 | UX §6.6 |
| 多提供商 §3.3 | T1.1/T5.4 | ARCH §4 / UX §6.10 |
| 技能 §5.5 | T5.5 | ARCH §5.6 |
## 4. 后续(原型外,对应规格的 P2/后续标注)
- 多租户 + Auth§9.1Repository 层加 `owner_id` 校验,接 Auth.js/OAuth。
- 向量检索P2`select_relevant_entities` 接口后接 pgvector + 嵌入服务。
- 专门队列BackgroundTasks → arq/Celery接口不变
- 全书一致性回归扫描、社区 Skill 市场、夜读模式。

569
PRODUCT_SPEC.md Normal file
View File

@@ -0,0 +1,569 @@
# 网文创作工作流 · 产品规格说明书Product Specification
> 面向中文网文作者的 AI 辅助创作工作流。**以 Web 应用形态交付**(让非技术作者零门槛使用),后端经 **LLM 网关多提供商适配** 做多 Agent 编排Claude / DeepSeek / Kimi / GPT / Gemini 等可路由)。
> 本文档为产品规格(含问题定义、功能范围、系统架构、数据模型、接口与实施路线),评审通过后进入实现。配套 UX/UI 规格见 [UX_SPEC.md](./UX_SPEC.md)。
---
## 0. 文档状态
| 项 | 内容 |
|---|---|
| 阶段 | 方案设计(未编码) |
| 目标场景 | 中文网文(连载长篇) |
| 交付形态 | Web 应用(前端 Next.js/TS + 后端 Python/FastAPI + LangGraph 编排 + Postgres模型经 **LLM 网关多提供商适配**Claude / DeepSeek / Kimi / GPT / Gemini / 通义 / GLM 等可路由可回退) |
| 优先痛点 | ① 长篇一致性 ② 伏笔追踪 ③ 文风模仿 ④ 节奏与爽点(四者并列) |
| 设计原则 | 不可变数据(记忆记录追加/保留历史不就地覆盖、单一职责高内聚、AI 在系统边界校验输入 |
---
## 1. 问题陈述
中文网文作者的核心痛点(调研结论,按优先级):
1. **长篇一致性** — 写到几十万字,人物性格突变、设定遗忘、时间线矛盾。
2. **伏笔追踪** — 埋的线收不回来,缺乏"埋设→回收"的显式账本与逾期提醒。
3. **文风模仿** — AI 续写不像作者本人,机翻腔、风格漂移。
4. **节奏与爽点** — 不懂网文节奏(黄金三章、章末钩子、爽点密度),中期注水拖沓。
现有工具NovelCrafter / Sudowrite / novel-writer / ai-novel-workspace已解决"设定库 + 基础一致性校验 + 文风分析",但**伏笔到期提醒**和**中文网文节奏引擎**是空白点,**长篇记忆衰减**仍是命门。本工作流聚焦这四点做深。
---
## 1.5 设计哲学:把写小说当软件工程
本工作流的内核是**把长篇创作当成软件工程来做**——先立架构、再对着规格生成、每章写完即测、全程维护单一真相源。这不是硬套:作者圈本就有一批工程化方法论(雪花写作法=自顶向下逐步求精、三幕/起承转合=架构模式、故事圈=状态机、Save the Cat 节拍表=规格清单、Story Bible=单一数据源AI 只是把它自动化、可校验化。
### 软件工程 ↔ 写小说 映射
| 软件工程 | 写小说 | 对应模块 |
|---|---|---|
| 需求 / PRD | 立意、核心卖点、目标读者、"一句话故事" | 立项 |
| 架构设计 | 世界观、力量体系、故事结构选型 | 设定库 |
| 数据模型 / Schema | 人物卡、设定集(实体定义) | 角色生成器 + 设定库 |
| 接口契约 | 人物的动机/目标——约束行为,像 interface | 人物卡 |
| 模块分解 | 卷 → 章 → 场景(场景 ≈ 函数) | 大纲 |
| 全局状态 | 世界观 + 时间线 + 人物当前状态 | 记忆库(真相源) |
| 不变量 / 断言 | 人物不崩、设定不违例、时间线自洽 | 一致性校验 |
| 单元测试 | 每章写完即审:一致性/伏笔/文风/节奏 | 四审 |
| 依赖图 | 伏笔(埋设 → 回收),逾期 = 未满足依赖 | 伏笔账本 |
| CI / 回归测试 | 阶段性全书一致性扫描 | 一致性校验 |
| 重构 | 改稿——剧情(行为)不变,改善文笔/结构 | 改稿 |
| Lint / 风格指南 | 文风指纹 + 四级规则 | 文风模仿 + 规则积累 |
| 技术债 | 没填的坑、注水段落 | 伏笔账本 + 节奏引擎 |
### 三个命门级工程思想
1. **Spec-first不让 AI 一口气瞎写 10 万字** — 先立架构(世界观+人物+大纲),再对着规格逐章生成。这是防"一致性崩坏"的根本(参考 novel-writer 的 Spec-Kit 范式)。
2. **单一真相源 + 纯函数** — 记忆库是全局状态,每章是 `章 = f(大纲, 选中的状态, 文风指纹)`"选中"=确定性按需选择非向量检索§3.4),写完更新状态。即本文档"记忆即真相源"§3.2)的由来。
3. **测试左移到每一章** — 一致性/伏笔/文风/节奏校验就是 CI每章跑一遍而非写完整本才发现崩盘。即"写完即审"四审§4
### 工程化流水线(对应 §6 工作流)
```
需求 → 立项(一句话故事 + 卖点)
架构 → 世界观圣经 + 故事结构选型
建模 → 人物卡 + 群像(角色生成器批量产出)
接口 → 分卷分章大纲 + 场景清单(雪花式细化)
实现 → 逐章生成(纯函数)
测试 → 写完即审(四审 = 测试套件)
集成 → 验收 → 更新全局状态 + 伏笔账本
回归 → 阶段性全书一致性扫描
重构 → 改稿(保持剧情,改善表达)
```
---
## 2. 设计目标与非目标
### 目标
- 作者从"一句灵感"走到稳定连载,全程有记忆、有校验、有提醒。
- 每章写完即审:一致性 / 伏笔 / 文风 / 节奏四审。
- 记忆随写作渐进积累,对抗长篇上下文衰减。
- 作者始终掌控创意AI 是"灵感增幅器 + 质检员",不是全自动黑箱。
### 非目标(第一版不做)
- 不做多人实时协作 / 多人共编一本(先单作者多作品)。
- 不做模型微调(用提示工程 + 文风指纹注入实现文风模仿)。
- 不做内容分发 / 发布平台对接。
- 不做移动原生 App先响应式 Web
---
## 2.5 功能总览(含优先级)
优先级:**P0** = MVP 必备(先跑通一章闭环);**P1** = 核心差异化(产品价值所在);**P2** = 体验增强。⭐ = 市面空白的差异化王牌。
> 注:优先级是**开发排序**非痛点重要性。§0 四大痛点同等重要;一致性是 MVP 地基故列 P0其余三者作为差异化王牌列 P1。
### 立项与设定
| 功能 | 优先级 | 说明 |
|---|---|---|
| 引导式立项 | P0 | 一句灵感 → 世界观/主角/金手指/总纲,自动建库 |
| 设定库 (Codex) | P0 | 人物卡 + 世界观结构化管理 |
| 世界观设计器 | P1 | worldbuilder 独立生成/扩展世界观、力量体系、势力、地理(立项已含基础版) |
| ⭐角色生成器 | P1 | 一句话 → 完整角色卡(背景/性格/弧光/关系群像批量生成防雷同§4.5 |
| 文风学习 | P1 | 上传样本 → 生成文风指纹9 通用维 + 中文 7 维,带原文证据) |
### 写作
| 功能 | 优先级 | 说明 |
|---|---|---|
| 分卷分章大纲 | P0 | AI 排骨架;⭐提示伏笔回收窗口 |
| 整章生成 | P0 | 注入设定+文风,产出本章草稿 |
| AI 续写 / 扩写 | P2 | 卡文时贴合语气续写 |
| 章节管理 | P0 | 卷/章组织、草稿/已验收状态 |
### 四大质检(写完即审)
| 功能 | 优先级 | 说明 |
|---|---|---|
| 长篇一致性校验 | P0 | 比对历史摘要,报性格/设定/时间线冲突,标 `[CONFLICT]` |
| ⭐伏笔账本 + 到期提醒 | P1 | 追踪埋设→状态→回收窗口,逾期自动 `OVERDUE` |
| 文风漂移检测 | P1 | 每段对指纹打分,漂移段回炉 |
| ⭐中文网文节奏引擎 | P1 | 注水检测、章末钩子、爽点密度、情绪曲线 |
### 记忆与积累
| 功能 | 优先级 | 说明 |
|---|---|---|
| 章节事实摘要 | P0 | 每章沉淀客观事实,抗上下文衰减 |
| 时间线管理 | P2 | 事件→章节→时间点 |
| 设定决议记录 | P2 | 讨论过的设定决定留痕 |
| ⭐渐进式规则积累 | P2 | 审稿发现随手存为四级规则,越用越懂你 |
### 管理与底层
| 功能 | 优先级 | 说明 |
|---|---|---|
| 伏笔看板 | P1 | 一眼看开着/快到期的线 |
| 审稿报告页 | P1 | 四审结果汇总一页,作者裁决 |
| 多提供商/多模型路由 | P0 | LLM 网关:能力档位映射到 Claude/DeepSeek/Kimi/GPT 等可换可回退§3.3 |
| 长记忆 Prompt Caching | P0 | 稳定前缀缓存,省约 90% 重复 token |
| 确定性按需注入 | P0 | 写章只调相关设定(显式点名+主角+近况;向量检索为 P2 |
| 技能系统 (Skill 扩展) | P2 | 内置能力即 skill支持题材模板/用户自定义/克隆改§5.5 |
**MVPP0闭环**:立项 → 设定库 → 大纲 → 写章 → 一致性校验 → 验收(更新摘要+记忆),底层带多模型路由 + 缓存 + 检索。
**第二期P1**:上齐伏笔账本、文风指纹/漂移、节奏引擎、伏笔看板、审稿报告——即四张差异化王牌。
**第三期P2**:续写/扩写、规则积累、时间线、决议记录等增强项。
---
## 3. 系统架构
### 3.1 技术栈与分层
| 层 | 选型 | 职责 |
|---|---|---|
| 前端 | Next.js (React/TS) | 设定库(Codex)、章节管理、伏笔账本看板、四审报告、AI 续写交互 |
| 后端 | Python / FastAPI + LangGraph | 多 Agent 编排(写章→四审→验收 HITL、记忆检索注入、模型路由 |
| LLM 网关 | 多提供商适配层Anthropic / OpenAI 兼容: DeepSeek·Kimi·Qwen·GLM / Gemini | 统一接口 + 能力档位路由 + 能力降级 + 故障回退(见 §3.3 / §3.4 |
| 存储 | **Postgres**(无向量) | 记忆真相源 + 确定性按需注入(显式+近况,抗上下文衰减);向量检索为 P2 |
### 3.2 数据模型(记忆即真相源)
记忆从「Markdown 文件」升级为「数据库表」,但**真相源**地位不变:每次写章前按本章涉及实体检索并注入,每次验收后增量更新。
```
users(id, email, display_name, created_at) -- 多租户主体(后续); 原型单用户 stub; projects.owner_id 指向此
projects(id, owner_id, title, genre, logline, premise, theme, selling_points, structure, created_at) -- logline=一句话故事; premise=总纲; theme=立意; selling_points=卖点; structure=故事结构选型(三幕/故事圈)
characters(id, project_id, name, role, traits, appearance, motive, backstory, arc, speech_tics, tags, relations, first_chapter, latest_state) -- role=定位(主角/CP/对手/导师/工具人); backstory=背景故事; arc=人物弧光; tags=人设标签/萌点
world_entities(id, project_id, type, name, rules, first_chapter, latest_state)
outline(id, project_id, volume, chapter_no, beats, foreshadow_windows) -- foreshadow_windows=json数组(一章可关联多条伏笔回收窗口)
chapter_digests(id, project_id, chapter_no, facts, created_at) -- 每章事实摘要,防矛盾的关键
foreshadow(id, project_id, code, title, status, planted_at, expected_close_from, expected_close_to, importance, links, progress) -- 伏笔账本(§4.2)
style_fingerprint(id, project_id, dimensions_json, evidence_json) -- 文风指纹(§4.3)
rules(id, project_id, level, content) -- 四级规则: global/genre/style/project
chapters(id, project_id, volume, chapter_no, content, status, version, created_at) -- version 支持改稿回溯
chapter_reviews(id, project_id, chapter_no, chapter_version, conflicts, foreshadow_sug, style, pace, health_score, decisions, created_at) -- 四审留痕 + 裁决, 支撑审稿历史(§4)
timeline(id, project_id, event, chapter_no, story_time, created_at) -- 时间线: 事件→章节→故事内时间(P2; MVP 先由 chapter_digests 推导)
decisions(id, project_id, topic, decision, created_at) -- 设定讨论决议留痕(P2)
```
**不可变原则**:摘要/伏笔状态变更**追加新行**保留历史(带 `created_at`),便于回溯"第 X 章为什么这样写";不就地覆盖旧设定,冲突显式标 `[CONFLICT]` 待作者裁决。
> 注:上表为**创作数据**表。原型用**确定性按需注入**(显式点名+主角+近况,无向量列);向量检索为 P2届时再加 `embedding` 列 + 嵌入服务。`users/owner_id` 为多租户接缝,原型单用户 stub。运营/系统表(凭据/用量/技能/档位路由/jobs见 ARCHITECTURE §3.1。
### 3.3 LLM 网关与模型路由(多提供商,不绑定单一厂商)
系统**不绑定单一 LLM 提供商**。后端经一层 **LLM 网关Provider Adapter** 统一调用Agent/Skill 只声明**能力档位tier**,由网关按配置映射到具体「提供商 + 模型」。
**统一内部接口**(网关对上层暴露):`input易变内容/ system稳定块可标缓存/ streaming / 结构化输出 / 思考`(字段契约见 ARCHITECTURE §4.1)。各提供商以适配器实现:
- **OpenAI 兼容适配器**:一套覆盖 OpenAI、DeepSeek、Kimi(Moonshot)、通义千问(Qwen)、智谱(GLM) 等(多数提供 OpenAI 兼容端点)。
- **Anthropic 适配器**Claude Messages API独立请求形态
- **Gemini 适配器**Google。
**能力档位 → 候选模型**(可在 *全局 / 项目 / 单 Agent* 三级覆盖;列首为推荐默认):
| 档位 | 用于 | 候选模型(跨提供商,作者可选) |
|---|---|---|
| **写手档**(创意/文笔) | worldbuilder / character-gen / writer | Claude Opus · DeepSeek-V3 · GPT 高配 · Kimi · 通义 · GLM |
| **分析档**(长上下文/推理) | outliner / continuity / foreshadow / 文风指纹提取 | Claude Sonnet · DeepSeek-R1(推理) · GPT mini · Kimi(长文) |
| **轻量档**(高频廉价) | 文风漂移打分 / 节奏检测 | Claude Haiku · DeepSeek · Qwen-turbo · GLM-flash |
- **能力协商 + 降级**:并非所有提供商都支持 prompt caching / 结构化输出 / 工具调用 / 思考;网关探测能力并优雅降级(如不支持原生结构化输出 → 改 JSON 提示 + schema 校验重试)。
- **故障回退fallback**:某提供商限流/故障时,按档位回退到备用提供商。
- **凭据隔离**:各提供商 API Key 由后端密钥管理,按用户/项目隔离。
- **中文网文优势**DeepSeek / Kimi / 通义 / GLM 在中文语感与成本上有优势,作者可自由路由(如写手用 Claude 求文笔、校对用 DeepSeek 降本)。
**跨提供商通用约定**
- 思考/推理有则启用Claude 自适应思考、DeepSeek-R1 推理链)。
- 结构化输出:优先各家原生 JSON Schema不支持则 JSON 提示 + 校验重试(网关统一封装)。
- 写章/续写一律 streaming避免长输出 HTTP 超时)。
- 定价随提供商而异,由网关按 provider+model 记账。
### 3.4 为什么直连各家 API经适配层而非厂商 Agent 运行时/CLI
| 方案 | 是否采用 | 原因 |
|---|---|---|
| **直连各提供商 API + 自建适配/编排层** | ✅ | 多提供商可换可回退、确定性编排循环、自己路由模型与注入记忆——正是本场景 |
| 厂商无头 CLI`claude -p` 等) | ❌ | Web 后端 shell-out 进程是反模式,并发/上下文/错误难管,且锁定单厂商 |
| 厂商托管 Agent 运行时(如 Anthropic Managed Agents 等) | ❌ | 托管 agent loop + 沙箱容器,本工作流不需要,且锁定单厂商 |
### 3.5 Prompt Caching —— 长记忆注入的成本命门
网文几十万字,每章注入世界观+人物卡+章节摘要+文风指纹,不缓存就每章重复烧数万 token。
> **缓存机制各家不同**Claude 显式 `cache_control`、OpenAI/DeepSeek 自动前缀缓存、部分提供商暂无),由 LLM 网关按提供商封装;但下述「稳定前缀」原则**跨提供商通用**,不支持缓存的提供商自动跳过、不影响正确性。
- **经济性**(以支持缓存的提供商为例):缓存读取 ≈ 0.1× 输入价(省约 90%)。
- **前缀匹配规则**`tools → system → messages`,前缀任何一字节变化即全部失效):
1. **只缓存稳定内核**:世界观硬规则 + 已定型角色 + 文风指纹放 `system` 并打 `cache_control` 断点;**易变部分**(本章实体的 `latest_state`、新增角色、本章大纲)放 `messages` 末尾、断点之后注入——否则角色一新增/状态一更新就整块缓存失效。
2. **绝不把章号/时间戳/UUID 拼进 system 前缀**——会让缓存每次失效。
3. 记忆 JSON **排序序列化**`sort_keys`),保证字节稳定,否则静默不命中。
4. 作者连写一卷可用 **1h TTL** 让世界观缓存跨章存活。
5. 用网关返回的缓存命中指标(如 `cache_read` token 数)验证;恒为 0 即存在静默失效源或该提供商不支持缓存。
6. 记忆别全塞——确定性选择本章相关实体(显式点名+主角+近况)后再注入(兼顾成本与上下文衰减)。
### 3.6 多 Agent 编排(后端 LangGraph 示意)
```python
from llm_gateway import gateway # 统一网关: 多提供商 + 档位路由 + 缓存/降级/回退
# 写手:写手档(默认 Claude Opus可按配置换 DeepSeek/Kimi…) + 稳定前缀缓存 + 文风指纹
async def write_node(s):
return await gateway.run(LlmRequest(
tier="writer", stream=True,
system=[Block(text=style_fingerprint + world_core, cache=True)], # 稳定内核→缓存
input=volatile_state + chapter_outline, # 易变,断点之后
))
# LangGraph: write ─並行→ 四审(只读, 结构化输出) ─→ collect ─interrupt→ 作者验收
# 连续性/伏笔(分析档) + 文风漂移/节奏(轻量档)
# 验收(HITL 恢复) → 单事务: 章节版本 + 伏笔账本 + 章节摘要 + 人物 latest_state
```
---
## 4. 四大核心模块设计
### 4.1 长篇一致性(实体记忆 + 校验)
**机制**
- **章节事实摘要**每章验收时continuity 角色提炼该章"发生的客观事实"(谁做了什么、状态变化、新增设定),追加进 `chapter_digests` 表。这是对抗上下文衰减的核心——不靠塞全文,靠塞结构化摘要。
- **实体关系(轻量知识图谱)**:人物/势力/物品以结构化条目维护(`characters` / `world_entities` 表),关键字段含 `first_chapter``latest_state`
- **写前检索 + 写后校验**
- 写本章前:确定性选择本章涉及实体(显式点名+主角+近况),注入其最新状态。
- 审稿时continuity 角色比对新章与 `chapter_digests` + `characters`,输出结构化冲突清单(性格突变 / 设定矛盾 / 时间线冲突),标 `[CONFLICT]`
**一致性 Bug 校验清单**(参考 *Lost in Stories 2026* 分类)
- 人物:性格漂移、能力前后不符、外貌/称呼不一致
- 设定:力量体系违例、地理矛盾、势力关系错位
- 时间:事件顺序倒错、跨度不合理、季节/年龄漂移
### 4.2 伏笔追踪(伏笔账本 — 差异化创新点)
现有工具最多做到 `threads.md` 列表,**没有到期提醒**。本模块做显式账本。
**`foreshadow` 表记录(示例,前端以卡片呈现)**
```yaml
code: F-012
title: 神秘玉佩
status: OPEN # OPEN / PARTIAL / CLOSED / OVERDUE
planted_at: 8 # 埋设章号
content: 主角母亲遗物,背面有未知符文
expected_close_from: 40 # 预期回收窗口
expected_close_to: 60
importance: 主线
links: [人物:母亲, 势力:符文宗]
progress:
- { chapter: 23, note: 符文短暂发光, status: PARTIAL }
```
**自动提醒逻辑**
- **验收后**`POST /projects/:id/chapters/:no/accept`)扫描:当前章号 > 某伏笔 `expected_close_to` 且状态非 CLOSED → 置 `OVERDUE`,在审稿报告与伏笔看板提醒。
- **排大纲时**`POST /projects/:id/outline`)主动提示"以下伏笔接近回收窗口,可考虑安排"。
- **审稿时**`POST /projects/:id/chapters/:no/review`)检测本章是否意外引入新伏笔(自动建议登记)或回收了某伏笔(自动建议改状态)。
### 4.3 文风模仿(文风指纹 + 双轨打分)
**学习阶段(`POST /projects/:id/style`**
- 上传作者 3-5 篇样本(建议 ≥5 万字)。
- 后端 **style-auditor 角色**提取**文风指纹**(提取是一次性重分析任务,走 **分析档**;后续每段漂移打分是高频轻任务,走 **轻量档**——经 LLM 网关路由,见 §3.3),维度参考 ai-novel-workspace
- 通用 9 维:句长分布、段落节奏、对话/叙述比、修辞密度、视角人称、情绪基调、词汇丰富度、标点习惯、节奏感。
- 中文 7 维:文白比、四字结构频率、量词习惯、语气词、成语/俗语密度、网络用语、方言/口癖。
- **每个维度结论必须附原文引用作为证据**,存入 `style_fingerprint` 表。
- 支持增量更新(同端点传 `mode=update` 补样本合并)。
**生成阶段**
- 写本章(`POST /projects/:id/chapters/:no/draft`)时注入文风指纹作为约束。
- **双轨打分**:每段生成后 style-auditor 对照指纹打相似度分,低于阈值 → 标注漂移段落,前端可一键回炉重写。
### 4.4 节奏与爽点(中文网文节奏引擎 — 差异化创新点)
内置中文网文模板(存入 `rules` 表的 `genre` 级),这是对英文工具的降维差异。
**模板库**
- **黄金三章**:前三章必须完成的钩子(金手指亮相、冲突立起、目标明确)。
- **章末钩子**:每章结尾留悬念/反转/期待点。
- **爽点密度**:按字数设定爽点节拍(如每 2-3 千字一个小爽点,每卷一个大高潮)。
- **情绪曲线**:抑扬节奏,避免长段平铺。
**pace-checker 角色校验(审稿 `POST /projects/:id/chapters/:no/review` 内)**
- 检测注水段落(信息密度过低、重复铺陈)。
- 检测章末是否有钩子。
- 输出本章"爽点节拍图"与情绪曲线,对照模板给出节奏建议。
---
### 4.5 角色生成器Character Generator — 直击群像痛点)
立项只生成主角;配角/群像靠这个专门工具一键生成,解决"群像刻画力不从心、配角一多就乱、人设重复"。
**输入**:一句话需求 + 世界观约束(自动从设定库读取)。
> 例:"给我一个亦正亦邪的女二,和主角有宿命纠葛,出身敌对势力"
**产出:一张完整结构化角色卡**
| 维度 | 内容 |
|---|---|
| 基础 | 姓名(取名风格契合世界观)、性别、年龄、身份/职业 |
| 外貌 | 外形 + 标志性细节(疤/配饰/习惯动作) |
| 性格 | 框架落地(核心-表层-阴影三层 / 大五人格)+ 核心动机、欲望、恐惧、价值观 |
| 背景故事 | 出身、关键经历、创伤/转折点 |
| 口癖/语言风格 | 用词偏好、口头禅 → 喂给文风一致性,让对话有辨识度 |
| 人物弧光 | 起点 → 转变 → 终点(与剧情挂钩) |
| 关系网 | 自动与已有角色建边(宿敌/师徒/CP…写入 `relations` |
| ⭐网文专属 | 角色定位(主角/CP/对手/导师/工具人)、能力契合力量体系、人设标签/萌点 |
**三个关键能力**(区别于"随便生成一段人设"
1. **世界观一致性校验**:入库前由**编排器**追加一道 continuity 检查(非 character-gen 直接互调,符合 §5.4 "agent 经记忆库交换"),确认角色背景/能力不与既有设定、力量体系冲突。
2. ⭐**群像批量生成 + 防雷同**:一次配一组配角,自动分配差异化定位与性格,避免"一群人一个模子"。
3. **直接入库**:输出结构化卡 → 写入 `characters` 表(角色行即注入单元),立即进入写章时的确定性选择注入。关系网为行内 `relations` 冗余存储,群像批量建边时由**编排器统一维护双向边**A↔B 同事务写两侧)。
**接口**`POST /projects/:id/characters/generate`(前端"AI 生成角色");批量版传 `count` + 定位列表。走**写手档**(创意丰富度,默认 Claude Opus可换 DeepSeek/Kimi由后端 **character-gen 角色**执行,结构化输出经网关统一封装。
---
## 5. 多 Agent 编排
### 5.1 设计原则:单一职责 + 确定性编排
- **Agent = 一个独立的认知任务**,有自己的 system prompt、模型、约束契约高内聚低耦合呼应 §1.5 工程哲学)。
- **Agent = 一组带固定角色的 LLM 调用(经网关)**;由后端**编排器**确定性串联,并按档位路由到不同提供商+模型(见 §3.3 / §3.4)。
- **不是所有功能都做成 Agent**——只有需要 LLM 判断的才是 Agent
| 类型 | 例子 | 实现 |
|---|---|---|
| **AgentLLM 认知任务)** | 世界观设计、角色生成、写章、连续性校对、文风/节奏审查 | LLM 调用(经网关) |
| **确定性代码(非 Agent** | 伏笔到期扫描、相关实体选择、状态写库、规则注入、缓存 | DB 查询 / 确定性选择 / 普通后端逻辑 |
### 5.2 Agent 花名册(按阶段)
**架构 / 建模阶段**
| Agent | 档位(默认模型) | 输入 | 输出 | 约束 |
|---|---|---|---|---|
| **worldbuilder**(世界观设计师) | 写手档Claude Opus | 立项需求 + 题材 | 世界观/力量体系/势力/地理/规则 | 内部自洽,硬规则显式标注(供后续校验引用) |
| **character-gen**(角色设计师) | 写手档Claude Opus | 一句话需求 + 世界观约束 | 结构化角色卡 / 群像 | 须过一致性校验,群像防雷同 |
**规划阶段**
| Agent | 档位(默认模型) | 输入 | 输出 | 约束 |
|---|---|---|---|---|
| **outliner**(大纲师) | 分析档Claude Sonnet | 总纲 + 伏笔回收窗口 | 分卷分章大纲 + 场景清单 | 不写正文,只定骨架与节拍 |
**写作阶段**
| Agent | 档位(默认模型) | 输入 | 输出 | 约束 |
|---|---|---|---|---|
| **writer**(写手) | 写手档Claude Opus | 单章大纲 + 检索到的记忆 + 文风指纹 | 章节草稿 | 不改设定,冲突需上报 |
**质检阶段(写完即审,并行)**
| Agent | 档位(默认模型) | 输入 | 输出 | 约束 |
|---|---|---|---|---|
| **continuity**(连续性校对) | 分析档Claude Sonnet | 草稿 + `chapter_digests` + 人物卡/世界观 | 冲突清单(结构化) | 只校验不改写 |
| **foreshadow-analyst**(伏笔分析) | 分析档Claude Sonnet | 草稿 + 伏笔账本 | 本章新埋/回收的伏笔建议 | 只建议,登记由作者确认(到期扫描是代码) |
| **style-auditor**(文风审查) | 轻量档(打分)/ 分析档(指纹提取) | 草稿 + 文风指纹 | 漂移段落 + 评分 | 只评不改 |
| **pace-checker**(节奏检查) | 轻量档Claude Haiku | 草稿 + genre 规则 | 节奏报告 | 只评不改 |
### 5.3 编排流(后端确定性串联)
```
立项 ─────────────> worldbuilder ─> character-gen 建世界观 + 群像,入库
POST /outline ────> outliner 产出分卷分章 + 场景清单
POST /draft ──────> writer 逐章生成草稿
POST /review ─┬──> continuity ┐
├──> foreshadow-analyst ├─ 并行四审,汇总报告
├──> style-auditor │
└──> pace-checker ┘
POST /accept ─────> 作者裁决 → 更新记忆表 + 伏笔账本 + 章节摘要 + 规则积累
(以上更新 = 确定性代码,非 Agent
```
> 注:立项(`POST /projects`)只产出**基础版**世界观+主角worldbuilder / character-gen 是**独立端点**§6用于后续扩展世界观与批量生成群像并非立项时自动全跑。上图把它们画在建模阶段是逻辑归类非单次调用链。
---
### 5.4 全局协作图与数据流
各 Agent 通过**记忆库(真相源)读写**协作,而非互相直接调用——记忆库是唯一的"集成总线"(呼应 §1.5 单一真相源)。
```
┌─────────────────────────────────────────┐
│ 记忆库(真相源 / 集成总线) │
│ world_entities characters outline │
│ chapter_digests foreshadow style_* │
│ rules chapters │
└─────────────────────────────────────────┘
写 ▲ 读 │ 读 │ 写 ▲ 读 │ 读 │
│ ▼ ▼ │ ▼ ▼
worldbuilder character-gen outliner writer 四审(只读,出报告)
(世界观) (角色/群像) (大纲) (写章) continuity/foreshadow/style/pace
验收(确定性代码) ◀──┘ 作者裁决
写回: chapter_digests / foreshadow状态
/ characters.latest_state / chapters.status
```
**读写矩阵**R=读W=写,·=无关)
| 表 \ Agent | worldbuilder | character-gen | outliner | writer | continuity | foreshadow-analyst | style-auditor | pace-checker | 验收(代码) |
|---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
| world_entities | **W** | R | R | R | R | · | · | · | · |
| characters | · | **R/W** | R | R | R | · | · | · | W (latest_state) |
| outline | · | · | **W** | R | · | · | · | · | · |
| chapters | · | · | · | **W** | R | R | R | R | W (status) |
| chapter_digests | · | · | · | R | R | · | · | · | **W** |
| foreshadow | · | · | R (窗口) | · | · | R | · | · | **W** (状态/到期) |
| style_fingerprint | · | · | · | R | · | · | **R/W** | · | · |
| rules | · | · | R | R | · | · | · | R (genre) | W (加规则) |
> 注:矩阵只列核心记忆表;`projects`(根表)、`timeline`/`decisions`P2从略。`style_fingerprint` 的 W 发生在**学文风**阶段非写章流水线character-gen 对 `characters` 的 R 用于建关系网与群像防雷同。
**三条关键规则**
1. **质检四审只读不写** — 它们出结构化报告,任何入库都经作者验收(确定性代码),守住"AI 是增幅器不是黑箱"。
2. **agent 之间不直接通信** — 全部通过记忆库交换,降耦合、可独立替换/升级单个 agent。
3. **写章是纯函数** — writer 只读不改设定,产出草稿;状态变更统一在验收阶段由代码完成。
---
### 5.5 技能系统Skill 扩展 — Agent 的可插拔形态)
把 §5 的 agent 抽象成**声明式、可插拔的 skill**:内置能力与用户自定义跑在同一套机制上。工程类比——**skill = 插件/包registry = 包管理器,内置 skill = 标准库**。
**Skill 定义(纯声明式,不含可执行代码)**
```yaml
name: 修仙力量体系生成器
description: 何时使用——需要为修仙题材设计境界/功法/灵根体系时
tier: writer # 能力档位(由 LLM 网关映射 provider+model);亦可写 provider:model 锁定
system_prompt: "你是修仙世界观设计师……"
input_schema: { ... } # 结构化输入
output_schema: { ... } # 结构化输出,过 schema 校验
reads: [world_entities] # 声明式表权限(只能读这些)
writes: [world_entities] # 只能写这些
genre: 修仙 # 适用题材(可空=通用)
examples: [ ... ] # few-shot
```
**Skill 分层**
| 层 | 例子 | 优先级 |
|---|---|---|
| 官方内置 | worldbuilder / character-gen / outliner / writer / 四审(即 §5 的 agent | P0/P1 |
| 题材模板 | 修仙力量体系、言情 CP 张力检测、悬疑诡计设计、地图生成 | P2 |
| 用户自定义 | 克隆内置改 prompt或从零写一个 | P2 |
| 社区市场 | 作者间分享/订阅 skill | P3远期 |
**`skills` registry 表**
```
skills(id, scope, name, description, model, system_prompt,
input_schema, output_schema, reads[], writes[], genre, owner, examples)
-- scope = builtin / custom / community; model 字段实为 tier(档位)或 provider:model
```
后端编排器加载 skill 配置 → 按声明的 tier/prompt/schema 经 LLM 网关发一次调用(复用 §5 的 agent 机制)。
**三条安全约束**(用户 skill = 不可信输入,呼应 §10 风险)
1. **纯声明式,不可执行代码** — skill 只是 prompt + schema 配置,杜绝任意代码执行。
2. **表权限强制** — 运行时只允许访问 skill 声明的 `reads`/`writes`,越权拒绝。
3. **写库仍经验收 gate** — 自定义 skill 的产出同样要过作者验收/四审才入库,不开后门。
---
## 6. 接口与前端动作
每个阶段对应一个后端端点和一个前端按钮:
| 端点 | 前端动作 | 阶段 | 作用 |
|---|---|---|---|
| `POST /projects` | 新建作品 | 立项 | 引导式建立世界观/人物/总纲,初始化设定库与规则 |
| `POST /projects/:id/world/generate` | 设计世界观 | 立项/写作 | worldbuilder 生成/扩展世界观、力量体系、势力、地理 |
| `POST /projects/:id/characters/generate` | AI 生成角色 | 立项/写作 | 一句话生成完整角色卡,入库;批量传 `count` + 定位 |
| `POST /projects/:id/style` | 学文风 | 立项 | 学习作者文风,生成文风指纹(`mode=update` 增量补样本) |
| `POST /projects/:id/outline` | 排大纲 | 规划 | 生成/更新分卷分章大纲,提示伏笔回收窗口 |
| `POST /projects/:id/chapters/:no/draft` | 写本章 | 写作 | 注入记忆+文风产出本章草稿streaming |
| `POST /projects/:id/chapters/:no/review` | 审稿 | 审稿 | 四审并行,输出冲突/漂移/节奏报告 |
| `POST /projects/:id/chapters/:no/accept` | 验收 | 验收 | 作者裁决后更新记忆表、伏笔账本、章节摘要 |
| `POST /projects/:id/chapters/:no/refine` | 回炉 | 写作 | 仅重写指定段落(文风漂移/选中段),返回新旧对比 |
| `POST /projects/:id/rules` | 加规则 | 任意 | 审稿发现的问题/亮点随手沉淀为项目规则 |
**典型循环**:新建作品 → 学文风 → 排大纲 →(写本章 → 审稿 → 验收)× N
---
## 7. 渐进式规则积累
四级规则,越具体越优先:`global → genre → style → project``rules``level` 字段)。
作者在审稿页发现 AI 的系统性问题或亮点,用"加规则"`POST /projects/:id/rules`)写入 `project`后续生成自动遵守。规则随项目成长AI 越用越"懂你"。
---
## 8. 差异化总结
| 能力 | 现有工具 | 本工作流 |
|---|---|---|
| 设定库 + 一致性校验 | ✅ 已普及 | ✅ + 章节事实摘要抗衰减 |
| 伏笔追踪 | ⚠️ 仅列表 | ✅ **账本 + 到期提醒** |
| 文风模仿 | ✅ 部分 | ✅ **指纹 + 双轨打分回炉** |
| 网文节奏 | ❌ 英文工具不懂 | ✅ **中文节奏引擎 + pace-checker** |
| 长篇记忆 | ⚠️ 衰减 | ✅ 写前检索 + 写后增量摘要 |
---
## 9. 实施路线(建议)
- **M1 骨架**:建**全部创作表**(含 ruleswriter/outliner 读它)+ 立项 + 写本章端点 + **提供商凭据配置(至少一家)** + 前端最小界面。
- **M2 一致性**continuity 角色 + 审稿端点冲突校验 + 章节摘要自动化。
- **M3 伏笔 + 节奏**`foreshadow` 账本表 + 到期提醒 + 伏笔看板pace-checker + genre 模板库。
- **M4 文风**:学文风端点(指纹)+ style-auditor 双轨打分。
- **M5 打磨**:验收全链路、规则积累、排大纲伏笔窗口提示、审稿报告页。
---
## 10. 风险与开放问题
- **上下文成本**:记忆注入要做相关性选择,避免无脑塞全量。需设计选择策略(按本章涉及实体过滤)。
- **一致性覆盖盲区**:确定性选择(显式+主角+近况)可能漏掉"久未出场又未点名"的远程回调实体,导致 AI 写出冲突——正是要防的失败。缓解:伏笔窗口关联实体强制纳入 + 按名匹配兜底 + 作者手动 pin彻底解决靠向量检索P2。详见 ARCHITECTURE §3.4。
- **文风量化阈值**:相似度打分阈值需实测校准,过严会频繁回炉。
- **作者掌控权**:所有 AI 改动需经验收(`accept`)裁决,冲突标注而非自动覆盖——守住"AI 是增幅器"的边界。
- **多提供商/多模型一致性**Agent 按档位路由到不同提供商+模型,各家在结构化输出/工具调用/缓存/思考的支持参差,网关需能力协商与降级;换提供商可能影响设定/文风表现,用「档位 + 提示约束」抹平;缓存按 `provider+model` 隔离;故障/限流支持回退。各提供商**数据合规与可用性**(境内外、隐私)需在配置层让用户/运营选择。
- **并发与限流**(多租户阶段):多用户写章并发调用 API需处理 429 限流SDK 自带退避)与按用户/作品的速率配额。原型单用户阶段仅需按作品配额。
- **用户自定义 Skill 安全**§5.5):用户 prompt 属不可信输入——需强制纯声明式(杜绝代码执行)、表权限白名单、提示注入防护,且产出仍经验收 gate 才入库。
---
## 参考项目
- [novel-writer](https://github.com/wordflowlab/novel-writer) — Spec-Kit 范式的小说工作流
- [ai-novel-workspace](https://github.com/cminn10/ai-novel-workspace) — Skills+Agents、四级规则、文风分析
- [StoryCraftr](https://github.com/raestrada/storycraftr) — CLI 式创作工具
- DOMEarXiv 2412.13575)— 动态分层大纲 + 知识图谱记忆
- RecurrentGPTarXiv 2305.13304)— 自然语言记忆滚动生成
- Lost in StoriesarXiv 2603.05890)— 长篇一致性 Bug 分类

502
UX_SPEC.md Normal file
View File

@@ -0,0 +1,502 @@
# 网文创作工作流 · UX/UI 规格说明
> 配套 [PRODUCT_SPEC.md](./PRODUCT_SPEC.md) 的用户体验与界面规格。视觉方向:**文学温暖 · 纸感**(暖米白、衬线、书卷气)。
> 形态:方案设计阶段产物,含信息架构、用户流程、页面线框、组件库、交互规范。
---
## 0. 文档状态
| 项 | 内容 |
|---|---|
| 阶段 | UX/UI 设计(未实现) |
| 目标用户 | 中文网文作者,长时间连续写作,需在「专注正文」与「纵览设定/伏笔」间切换 |
| 视觉方向 | 文学温暖·纸感:暖米白背景 + 宋体正文 + 朱砂印章红强调 |
| 平台 | 响应式 Web桌面优先平板适配不做移动原生 |
| 对齐 | 功能/端点/数据对齐 PRODUCT_SPEC.md §2.5 / §6 / §3.2P2 项如时间线/AI 续写扩写暂未画线框) |
---
## 1. UX 设计原则
承接 PRODUCT_SPEC.md §1.5「把写小说当软件工程」UX 层把工程纪律转译为作者无感的顺滑体验:
1. **正文至上Content-first** — 写作界面默认极致专注,设定/审稿/工具皆可收起,正文永远是视觉主角。
2. **AI 是副驾不是自动驾驶** — 所有 AI 产出都以「草稿/建议」形态呈现,作者一键采纳/拒绝/回炉;绝不静默改稿(对齐 PRODUCT_SPEC §10 作者掌控权)。
3. **状态可见(真相源外化)** — 人物、伏笔、时间线、一致性状态随手可查,写到几十万字也不"失忆"。
4. **测试即时反馈** — 四审结果像 IDE 的波浪线,写完即现、就地可跳转、可裁决。
5. **渐进披露** — 新手走引导式向导老手走快捷命令面板复杂能力skill、规则藏在二级不挡主路。
6. **书卷气** — 视觉传达"在稿纸上创作"的温度,降低 AI 工具的冰冷感,贴合创作者情绪。
---
## 2. 视觉设计 token文学温暖·纸感
### 2.1 色板
| 角色 | 名称 | 值 | 用途 |
|---|---|---|---|
| 背景 | 暖米白/稿纸 | `#F5F1E8` | 全局底色 |
| 卡片/面板 | 宣纸白 | `#FBF8F1` | 卡片、侧栏、模态底 |
| 正文文字 | 墨 | `#2B2620` | 正文、标题主色 |
| 次要文字 | 淡墨 | `#6B6356` | 注释、元信息 |
| 分隔线 | 米灰 | `#E5DDCD` | 描边、分隔 |
| **强调主色** | **朱砂(印章红)** | `#A23B2E` | 主按钮、链接、品牌、章节标记 |
| 强调浅 | 朱砂晕 | `#A23B2E14` | 选中态底、hover |
| 警告/冲突 | 赭红 | `#B5543A` | `[CONFLICT]`、错误 |
| 逾期 | 琥珀 | `#C8893A` | 伏笔 `OVERDUE`、提醒 |
| 通过/正常 | 黛绿 | `#5A6B4F` | 校验通过、已验收 |
| 信息 | 黛蓝 | `#4A5A6B` | 中性提示、链接备选 |
> 暗色不在本方案首版(作者选了纸感);后续可加「夜读模式」作为偏好开关。
### 2.2 字体
| 用途 | 字体栈 | 说明 |
|---|---|---|
| 正文(小说) | `"Noto Serif SC", "Songti SC", "宋体", serif` | 宋体,营造稿纸书写感 |
| 标题/卷章 | `"Noto Serif SC", "Source Serif 4", serif` | 衬线,带书卷气 |
| UI按钮/标签/菜单) | `"Noto Sans SC", "PingFang SC", system-ui, sans-serif` | 无衬线,保证小字 UI 清晰 |
| 数字/代码 | `"JetBrains Mono", ui-monospace` | 字数、章号、token |
### 2.3 排版与间距
- **正文**18px / 行高 **1.9**(舒朗稿纸感),段间距 0.8em,单栏最大宽 **720px** 居中。
- **字号梯度**12 / 14 / 16 / 18 / 22 / 28 / 36衬线大标题
- **网格**8px 基准;页面留白慷慨(卡片内边距 24px
- **圆角**6px柔和非全圆
- **阴影**:极淡暖调 `0 1px 3px #2B26200F`,避免硬投影。
- **描边**1px `#E5DDCD`,纸感分隔优先用细线而非阴影。
### 2.4 动效
- 时长 150250ms缓动 `ease-out`;面板展开/收起用 200ms。
- AI streaming文字逐字淡入打字机感光标朱砂色闪烁。
- 克制:无弹跳、无大幅位移,符合"安静书房"气质。
---
## 3. 信息架构IA
### 3.1 顶层导航(作品工作台左栏)
```
作品库(多作品)
└─ 单作品工作台
├─ ① 仪表盘 连载概览:进度/待回收伏笔/最近审稿
├─ ② 写作 核心写作界面(默认入口)
├─ ③ 大纲 分卷分章 + 场景清单
├─ ④ 设定库 人物 / 世界观 / 时间线Codex
├─ ⑤ 伏笔看板 埋设→回收 依赖图 + 到期提醒
├─ ⑥ 审稿 四审报告历史
├─ ⑦ 文风 样本管理 + 文风指纹
└─ ⑧ 规则 四级规则管理
设置(账号 / 偏好 / 模型与提供商 / 技能 Skill 库)
```
### 3.2 页面地图(对齐 PRODUCT_SPEC §6 端点)
| 页面 | 对应端点/功能 | 优先级 |
|---|---|---|
| 作品库 | 列表 + `POST /projects` | P0 |
| 立项向导 | `POST /projects` | P0 |
| 写作工作台 | `POST /projects/:id/chapters/:no/draft` `/review` `/accept` | P0 |
| 大纲编辑器 | `POST /projects/:id/outline` | P0 |
| 设定库 Codex | characters / world_entities | P0 |
| 角色生成器 | `POST /projects/:id/characters/generate` | P1 |
| 世界观设计器 | `POST /projects/:id/world/generate` | P1 |
| 文风学习 | `POST /projects/:id/style` | P1 |
| 审稿报告页 | `POST /projects/:id/chapters/:no/review` | P0(一致性)/P1(其余) |
| 伏笔看板 | foreshadow | P1 |
| 规则管理 | `POST /projects/:id/rules` | P2 |
| **模型与提供商设置** | LLM 网关配置PRODUCT_SPEC §3.3 | P0 |
| 技能库 | skills§5.5 | P2 |
---
## 4. 核心用户流程
### 4.1 主创作循环(工程化流水线的 UX 投影)
```
新建作品 ──▶ 立项向导(5步) ──▶ [可选] 学文风 / 世界观设计 / 生成群像
┌──────────── 写作工作台(主循环)────────────┐
│ 排大纲 → 写本章(草稿,streaming) → 审稿(四审) │
│ → 裁决冲突 → 验收(更新记忆+伏笔) │
└──────────── 循环 × N 章 ──────────────────┘
```
### 4.2 关键流程:写一章(最高频路径)
1. 作者在「写作」选中某章(大纲已排)→ 点 **写本章**
2. 正文区 streaming 出草稿;右侧「本章注入」实时显示本次喂给 AI 的设定/伏笔(可信度透明)。
3. 草稿完成 → 自动触发 **审稿**(四审并行,右侧报告区逐项亮起)。
4. 作者逐条处理:冲突 `[CONFLICT]` 就地高亮、点击跳转、采纳/忽略;文风漂移段一键回炉;节奏建议查看。
5. 满意后点 **验收** → 弹出「本次将更新」清单(章节摘要 / 伏笔状态 / 人物 latest_state→ 确认入库。
6. 伏笔看板/仪表盘随之刷新;逾期伏笔自动提醒。
### 4.3 关键流程:生成群像(角色生成器)
`设定库 → AI 生成角色 → 输入一句话需求 + 数量/定位 → 预览角色卡(可逐张编辑/重roll→ 过一致性校验 → 批量入库`
---
## 5. 全局布局
### 5.1 作品工作台外壳
```
┌──────────────────────────────────────────────────────────────┐
│ ☷ 墨痕 〈逐光而行〉 进度 32万字 · 第128章 ⚙ 头像 │ ← 顶栏(64px, 宣纸白)
├────────┬─────────────────────────────────────────────────────┤
│ 仪表盘 │ │
│ 写作 ● │ │
│ 大纲 │ (主内容区,随导航切换) │
│ 设定库 │ │
│ 伏笔 │ │
│ 审稿 │ │
│ 文风 │ │
│ 规则 │ │
│ │ │
│ ⚑ Skill│ │
└────────┴─────────────────────────────────────────────────────┘
左栏 72200px(可折叠为图标) 主区暖米白底
```
- 左栏图标 + 文字,当前项朱砂竖条 + 浅晕底。
- 顶栏:作品名(衬线)、连载进度、全局搜索、设置、头像。
---
## 6. 页面规格(线框 + 要点)
### 6.1 作品库Dashboard 入口)
```
┌──────────────────────────────────────────────────────────────┐
│ 墨痕 新建作品 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 我的作品 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 〈逐光而行〉 │ │ 〈青冥录〉 │ │ 新建 │ │
│ │ 玄幻 · 连载中 │ │ 仙侠 · 暂停 │ │ │ │
│ │ 32万字·128章 │ │ 8万字·40章 │ │ 从一句灵感 │ │
│ │ ⚑3待回收伏笔 │ │ ✓ 无待办 │ │ 开始一本书 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
- 作品卡:书名衬线大字、题材/状态、字数/章数、**待办徽标**(待回收伏笔、未处理冲突)。
- 新建卡常驻末位。
### 6.2 立项向导5 步,对齐 §3.2 projects 字段)
```
┌──────────────────────────────────────────────────────────────┐
│ 新建作品 步骤 ②/⑤ ● ● ○ ○ ○│
├──────────────────────────────────────────────────────────────┤
│ 一句话故事 (logline) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ 题材 [玄幻 ▾] 故事结构 [三幕 ▾] [故事圈] [雪花] │
│ 核心卖点 □逆袭 □打脸 □系统流 □双男主 +自定义 │
│ │
│ [ ← 上一步 ] [ 下一步:立意/总纲 → ] │
└──────────────────────────────────────────────────────────────┘
```
步骤:① 书名+题材 → ② 一句话故事+卖点+结构 → ③ 立意/总纲(premise/theme) → ④ 主角+金手指 → ⑤ 基础世界观worldbuilder 基础版,可生成可手填)。完成即建库,落 `projects` 各字段。
### 6.3 写作工作台 ★(核心页面)
三栏:左目录、中正文、右「设定 + 审稿」抽屉。专注模式可一键收起两侧。
```
┌────────┬──────────────────────────────────────┬───────────────┐
│ 目录 │ 卷三 · 风云会 │ 本章助手 ✕ │
│ │ ├───────────────┤
│ 卷一 ▾ │ 第 128 章 · 血脉觉醒 │ 本章注入(透明) │
│ 卷二 ▾ │ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │ · 主角:林衍 │
│ 卷三 ▴ │ │ · 伏笔F-012玉佩│
│ ·126 │ 夜色如墨,林衍盘膝而坐,体内那道 │ · 设定:血脉禁制│
│ ·127 │ 沉睡已久的血脉骤然苏醒…… │ │
│ ·128 ●│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ‹生成中 │ ── 四审 ── │
│ │ │ ✓ 一致性 │
│ │ [此段与第30章设定冲突] ⚠ │ ⚠ 1 处冲突 ▸ │
│ │ │ ◔ 文风 92% │
│ │ │ ⚠ 节奏:第3段注水│
│ +新章 │ 字数 2,340 · 自动保存于 14:32 │ │
│ │ [ ✍写本章 ] [ 🔍审稿 ] [ ✓验收 ] │ ⚑ F-012可回收 │
└────────┴──────────────────────────────────────┴───────────────┘
```
要点:
- **中栏正文**宋体、720px 居中、行高 1.9AI 段落淡入;冲突句下朱砂波浪线,行内挂 `⚠` 锚点。
- **底部操作条**`写本章 / 审稿 / 验收` 三主按钮 + 字数 + 自动保存时间。
- **右栏「本章助手」**
- *本章注入*:透明展示喂给 AI 的设定/伏笔(建立信任,对齐"状态可见")。
- *四审*:四项实时状态(✓/⚠/评分),点击展开详情、跳转正文锚点。
- *伏笔提示*:本章可回收的伏笔。
- **专注模式**`⌘\` 收起两侧,只剩正文 + 极简底栏。
### 6.4 审稿报告页(四审汇总,对齐 §4 / §5.2
```
┌──────────────────────────────────────────────────────────────┐
│ 第128章 审稿报告 健康分 82 [ 重新审稿 ]│
├──────────────────────────────────────────────────────────────┤
│ ① 一致性 (continuity) ⚠ 1 冲突 │
│ ⚠ [CONFLICT] 林衍血脉"先天觉醒"与第30章"后天淬炼"矛盾 │
│ ▸ 跳转第128章·第4段 ▸ 跳转第30章 [采纳改法] [忽略] │
│ │
│ ② 伏笔 (foreshadow-analyst) 2 建议 │
│ ⚑ 本章疑似回收 F-012(神秘玉佩) → [确认回收] [不是] │
检测到新埋线"残破地图" → [登记为伏笔] [忽略] │
│ │
│ ③ 文风 (style-auditor) ◔ 92% │
│ 第3段相似度 71%(偏低) → 机翻腔 [一键回炉] [查看对比] │
│ │
│ ④ 节奏 (pace-checker) ⚠ 注水 │
│ 爽点节拍图 ▁▃▅▂▁▇ 章末无钩子建议补悬念 │
│ │
│ [ 全部处理完 → 验收本章 ] │
└──────────────────────────────────────────────────────────────┘
```
要点:四审分区卡片;每条 finding 都有**就地跳转** + **裁决按钮**(采纳/忽略/回炉/登记顶部健康分0100。冲突=赭红、逾期=琥珀、通过=黛绿。
### 6.5 设定库 Codex人物 / 世界观 / 时间线)
```
┌──────────────────────────────────────────────────────────────┐
│ 设定库 [人物 ●] [世界观] [时间线(P2)] 🔍 [✦ AI生成角色] │
├──────────────────────────────────────────────────────────────┤
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 林衍 │ │ 苏挽月 │ │ 玄渊真人 │ │ │ │
│ │ 主角 │ │ 女二·CP │ │ 导师 │ │ 生成群像 │ │
│ │ 首现 ·1 │ │ 首现 ·12 │ │ 首现 ·5 │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ 点开 → 人物卡详情(背景/性格/弧光/口癖/关系网图) │
└──────────────────────────────────────────────────────────────┘
```
- 人物卡详情:左基础信息,右**关系网图**(与其他角色的边);字段对齐 `characters`role/backstory/arc/speech_tics/tags…
- 顶部 `✦ AI 生成角色` 打开角色生成器。
### 6.6 角色生成器(模态/侧抽屉,对齐 §4.5
```
┌────────────────────── AI 生成角色 ──────────────────────┐
│ 一句话需求 │
│ ┌────────────────────────────────────────────────────┐ │
│ │ 亦正亦邪的女二,与主角有宿命纠葛,出身敌对势力 │ │
│ └────────────────────────────────────────────────────┘ │
│ 数量 [1 ▾] 定位 [女二/CP ▾] ☑ 自动过一致性校验 │
│ [ ✦ 生成 ] │
├─────────────────────────────────────────────────────────┤
│ 预览(生成后) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 苏挽月 · 女二/CP ↻重roll ✎编辑 │ │
│ │ 性格: 表层清冷/内核执拗/阴影复仇 │ │
│ │ 背景: 灭门遗孤,混入仙门寻仇…… │ │
│ │ 关系: 与林衍→宿敌转知己 口癖:"……又如何" │ │
│ │ ✓ 一致性校验通过 │ │
│ └─────────────────────────────────────────────────┘ │
│ [ 丢弃 ] [ ✓ 入库 ] │
└─────────────────────────────────────────────────────────┘
```
群像批量时多张卡纵列,逐张 重roll/编辑/入库;底部「全部入库」。
### 6.7 大纲编辑器(分卷分章 + 场景)
```
┌──────────────────────────────────────────────────────────────┐
│ 大纲 〈逐光而行〉 [ ✦ AI 排大纲 ] │
├──────────────────────────────────────────────────────────────┤
│ 卷三 · 风云会 │
│ ├ 第126章 血脉初探 beats: 觉醒前兆 / 长老试探 │
│ ├ 第127章 暗流 beats: 同门构陷 │
│ └ 第128章 血脉觉醒 ⚑ 可回收 F-012 beats: 觉醒 / 反杀 │
│ 场景: ① 静室盘坐 ② 血脉暴动 ③ 长老来袭 │
加章 │
│ │
│ 侧提示: ⚑ F-012(玉佩)进入回收窗口(40-60章) 建议本卷安排 │
└──────────────────────────────────────────────────────────────┘
```
- 卷/章树状;每章显示 beats + **伏笔窗口徽标**(⚑)。
- 右侧/底部主动提示接近回收窗口的伏笔(对齐 §4.2)。
### 6.8 伏笔看板Kanban + 依赖,对齐 §4.2
```
┌──────────────────────────────────────────────────────────────┐
│ 伏笔看板 筛选[全部▾] [主线] [逾期]│
├───────────────┬───────────────┬───────────────┬──────────────┤
│ OPEN │ PARTIAL │ CLOSED │ ⚠ OVERDUE │
├───────────────┼───────────────┼───────────────┼──────────────┤
│ F-014 残破地图│ F-012 神秘玉佩│ F-003 师门令牌│ F-007 旧誓约 │
│ 主线·埋于128 │ 埋8 收40-60 │ ✓ 收于 95 │ 应收≤110·已128│
│ │ 进展:23章发光 │ │ 琥珀高亮 ⚑ │
│ ───────────── │ [窗口将至] │ │ [安排回收] │
│ F-018 … │ │ │ │
└───────────────┴───────────────┴───────────────┴──────────────┘
```
- 四列状态泳道;卡片含埋设/窗口/进展;**OVERDUE 列琥珀强调**。
- 点卡 → 详情 + 关联人物/势力 + 跳转埋设章。
### 6.9 文风学习(样本 + 指纹)
```
┌──────────────────────────────────────────────────────────────┐
│ 文风 [ 上传样本 ] │
├──────────────────────────────────────────────────────────────┤
│ 样本: 旧作01(3.2万) 旧作02(2.1万) 合计 5.3万字 ✓ │
│ [ ✦ 提取文风指纹 ] │
├──────────────────────────────────────────────────────────────┤
│ 文风指纹16 维,每维附原文证据) │
│ 句长分布 短句为主 ▏▎▍ ▸证据:"刀光一闪。血。" │
│ 文白比 7:3 ▸证据:"……乃天地至理也" │
│ 四字结构 高频 ▸证据:"风雷激荡,气吞山河" │
│ 口头禅/语气词 … │
└──────────────────────────────────────────────────────────────┘
```
- 每维**带原文引用证据**(对齐 §4.3);支持增量补样本。
---
### 6.10 模型与提供商设置LLM 网关配置,对齐 PRODUCT_SPEC §3.3
让作者按**能力档位**选提供商+模型、管理各家 API Key、配置回退。两块①档位路由 ②提供商凭据。
```
┌──────────────────────────────────────────────────────────────┐
│ 设置 模型与提供商 │
├──────────────────────────────────────────────────────────────┤
│ 能力档位路由 作用域 [全局 ▾] [本作品] │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ ✍ 写手档 创意/文笔 [Claude Opus ▾] 回退[DeepSeek-V3▾]│ │
│ │ 🔍 分析档 大纲/校对 [Claude Sonnet▾] 回退[DeepSeek-R1▾]│ │
│ │ ⚡ 轻量档 打分/节奏 [DeepSeek ▾] 回退[Qwen-turbo▾] │ │
│ └────────────────────────────────────────────────────────┘ │
│ 提示:作品级覆盖全局;单 Agent 可在 Skill 内再锁定 │
│ │
│ 提供商凭据 [ 添加提供商 ] │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ ● Anthropic ●●●●●●●●sk-…3f2 ✓已连接 caching✓ 思考✓ │ │
│ │ ● DeepSeek ●●●●●●●●sk-…9a1 ✓已连接 caching✓ │ │
│ │ ○ Kimi [ 输入 API Key ] [测试连接] │ │
│ │ ○ OpenAI [ 输入 API Key ] [测试连接] │ │
│ │ ○ 通义/GLM/Gemini … │ │
│ └────────────────────────────────────────────────────────┘ │
│ 能力徽标说明caching=前缀缓存 · 结构化 · 思考/推理 · 工具 │
│ │
│ 用量与花费(本月) Claude $12.3 · DeepSeek ¥8.6 │
│ [ 查看按档位/作品明细 ] │
└──────────────────────────────────────────────────────────────┘
```
要点:
- **档位路由**:三档各选「主模型 + 回退模型」;下拉只列**已连接**提供商的可用模型。**作用域切换**:全局默认 / 本作品覆盖(对齐 §3.3 三级覆盖;单 Agent 级在 Skill 内锁定)。
- **凭据管理**每个提供商一行——Key 脱敏显示、`测试连接`、连接状态点(绿/灰)、**能力徽标**caching / 结构化输出 / 思考 / 工具调用缺失则灰显。Key 仅后端密钥管理、前端永不回显明文。
- **能力降级提示**:若某档位选了不支持结构化输出/缓存的提供商,行内黄条提示"将自动降级为 JSON 提示+校验/跳过缓存"(对齐 §3.3 能力协商)。
- **用量花费**:按 `provider+model` 记账,月度概览 + 按档位/作品下钻(对齐 §3.3 记账)。
- **数据合规**:提供商行可标"境内/境外",供作者按隐私/合规取舍(对齐 PRODUCT_SPEC §10
- **空态**:未配置任何 Key 时,引导"至少连接一个提供商即可开始"并提供默认推荐Claude 求质量 / DeepSeek 求性价比)。
---
## 7. 组件库Component Inventory
| 组件 | 说明 | 关键状态 |
|---|---|---|
| AppShell | 顶栏 + 左导航 + 主区 | 导航折叠/展开 |
| ProjectCard | 作品卡 | 待办徽标 |
| Wizard | 分步向导 | 步进/校验 |
| Editor | 正文编辑器(宋体/720px | 编辑/streaming/只读 |
| ConflictMark | 正文内冲突锚点 | 朱砂波浪线 + 悬浮卡 |
| AssistantPanel | 右侧本章助手 | 注入/四审/伏笔 tab |
| ReviewCard | 四审分区卡 | ✓/⚠/评分 + 裁决按钮 |
| CharacterCard | 人物卡 | 列表态/详情态/生成预览态 |
| RelationGraph | 关系网图 | 节点/边 hover |
| ForeshadowCard | 伏笔卡 | OPEN/PARTIAL/CLOSED/OVERDUE |
| KanbanColumn | 看板泳道 | 四状态 |
| FingerprintRow | 文风维度行 | 维度值 + 证据展开 |
| GenerateModal | AI 生成模态(角色/世界观/大纲) | 输入/生成中/预览 |
| Button | 主(朱砂实心)/次(描边)/文字 | hover/loading/disabled |
| Badge | 徽标(伏笔/冲突/逾期/通过) | 4 语义色 |
| Toast | 轻提示(已保存/已入库) | 成功/警告/错误 |
| TierRouter | 档位→主/回退模型选择行 | 全局/作品作用域 |
| ProviderRow | 提供商凭据行 | 已连接/未连接/测试中 |
| CapabilityBadge | 能力徽标(缓存/结构化/思考/工具) | 支持/灰显缺失 |
| ApiKeyInput | API Key 脱敏输入 | 输入/已存/测试连接 |
按钮规范:**主按钮**朱砂实心白字;**次按钮**朱砂描边透明底AI 动作统一前缀 `✦`;破坏性动作(丢弃/删除)赭红文字 + 二次确认。
---
## 8. 交互规范(关键模式)
1. **AI 生成streaming**:按钮转 loading → 正文/卡片逐字淡入 → 完成后浮现操作条(采纳/回炉/丢弃)。可中途「停止」=前端 abort 中断 SSE 连接(无队列同步模型,已生成部分保留在草稿)。
2. **冲突裁决**`[CONFLICT]` 永不自动改稿;正文波浪线 + 报告条目双入口;裁决=`采纳改法 / 忽略 / 手动改`,忽略需记录(可沉淀为规则)。
3. **回炉重写**:文风漂移段/任意选中段 → `回炉` → 仅重写该段、保留上下文,新旧对比可 diff。
4. **验收 gate**:点验收弹「本次将更新」清单(章节摘要 / 伏笔状态 / 人物 latest_state / 触发的 OVERDUE→ 确认才写库(对齐 PRODUCT_SPEC §5.4 三规则)。
5. **本章注入透明化**:右栏始终显示本次喂给 AI 的设定/伏笔条目,可点开看原文,建立信任。
6. **快捷命令面板**`⌘K`):老手直达「写本章/审稿/生成角色/跳转伏笔/搜设定」。
7. **自动保存**:正文持续自动保存,显示「保存于 hh:mm」章节版本可回溯对齐 `chapters.version`)。
---
## 9. 关键状态设计
| 状态 | 表现 |
|---|---|
| 空(新作品无章节) | 纸感插画 + "从第一章开始" 引导按钮 |
| 生成中 | 打字机淡入 + 朱砂光标 + 可停止 |
| 四审进行中 | 四项骨架占位逐项点亮 |
| 冲突未决 | 顶部计数徽标 + 验收按钮置灰提示"尚有冲突" |
| 伏笔逾期 | 仪表盘/看板琥珀提醒 + 数字徽标 |
| 错误API 429/失败) | 友好文案 + 重试;不丢正文(已自动保存) |
---
## 10. 响应式与可访问性
- **断点**≥1280 三栏全开10241280 右栏转抽屉;<1024平板单栏 + 浮动工具侧栏抽屉化移动端只读+轻编辑
- **可访问性**正文/背景对比 7:1 on 米白达标朱砂红配文字另加图标/文案不单靠色全键盘可达焦点环朱砂色尊重 `prefers-reduced-motion`关打字机动效)。
- **长写护眼**行宽限 720px行高 1.9后续可加夜读模式与字号/行距偏好
---
## 11. 落地优先级(对齐 PRODUCT_SPEC §9 M1M5
- **M1P0**AppShell + 作品库 + 立项向导 + 写作工作台(正文+写本章) + 设定库基础 + **模型与提供商设置(至少连一个提供商)**
- **M2**审稿报告页(一致性) + 冲突就地裁决 + 验收 gate
- **M3**伏笔看板 + 大纲伏笔徽标 + 节奏报告
- **M4**文风学习页 + 漂移回炉交互
- **M5**角色/世界观生成模态规则管理命令面板技能库
---
## 附:与 PRODUCT_SPEC.md 的映射校验
| UX 页面 | PRODUCT_SPEC 模块/端点 |
|---|---|
| 写作工作台 | §5.2 writer + §6 draft/review/accept |
| 审稿报告页 | §4 四审 + §5.2 continuity/foreshadow/style/pace |
| 设定库/角色生成器 | §3.2 characters + §4.5 character-gen |
| 世界观设计器 | §5.2 worldbuilder + §6 world/generate |
| 大纲编辑器 | §5.2 outliner + §6 outline |
| 伏笔看板 | §4.2 foreshadow 账本 |
| 文风学习 | §4.3 style-auditor + §6 style |
| 本章注入透明化 | §3.5 检索注入 + §5.4 真相源 |
| 验收 gate | §5.4 三规则四审只读经验收入库 |
| 模型与提供商设置 | §3.3 LLM 网关档位路由/凭据/能力降级/回退/记账 |

37
alembic.ini Normal file
View File

@@ -0,0 +1,37 @@
[alembic]
script_location = packages/db/migrations
prepend_sys_path = .
path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

9
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock* ./
COPY packages ./packages
COPY apps/api ./apps/api
RUN uv sync --no-dev --frozen || uv sync --no-dev
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "ww_api.main:app", "--host", "0.0.0.0", "--port", "8000"]

29
apps/api/pyproject.toml Normal file
View File

@@ -0,0 +1,29 @@
[project]
name = "ww-api"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.111",
"uvicorn[standard]>=0.30",
"structlog>=24.1",
"cryptography>=43",
"ww-shared",
"ww-config",
"ww-db",
"ww-llm-gateway",
"ww-core",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["ww_api"]
[tool.uv.sources]
ww-shared = { workspace = true }
ww-config = { workspace = true }
ww-db = { workspace = true }
ww-llm-gateway = { workspace = true }
ww-core = { workspace = true }

View File

@@ -0,0 +1,44 @@
"""端点测试 fixtures用内存替身覆盖依赖注入有效加密 key无 DB/无网络)。"""
from __future__ import annotations
import os
import pytest
from cryptography.fernet import Fernet
from fakes_providers import FakeCredentialStore, FakeProviderProbe
from fastapi.testclient import TestClient
@pytest.fixture
def enc_key() -> str:
return Fernet.generate_key().decode()
@pytest.fixture
def store() -> FakeCredentialStore:
return FakeCredentialStore()
@pytest.fixture
def probe() -> FakeProviderProbe:
return FakeProviderProbe()
@pytest.fixture
def client(enc_key: str, store: FakeCredentialStore, probe: FakeProviderProbe) -> TestClient:
os.environ["CREDENTIAL_ENC_KEY"] = enc_key
from ww_config import get_settings
get_settings.cache_clear() # 让新 env 生效
from ww_api.main import create_app
from ww_api.services.provider_deps import (
get_credential_store,
get_provider_probe,
)
app = create_app()
app.dependency_overrides[get_credential_store] = lambda: store
app.dependency_overrides[get_provider_probe] = lambda: probe
return TestClient(app)

View File

View File

@@ -0,0 +1,15 @@
"""导出 OpenAPI 到 stdout前端 gen:api 离线消费CI 无需起服务)。"""
from __future__ import annotations
import json
from ww_api.main import app
def main() -> None:
print(json.dumps(app.openapi(), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,34 @@
"""structlog 结构化日志ARCH §9.3。dev 走彩色 console其余 JSON。"""
from __future__ import annotations
import logging
import structlog
from structlog.typing import Processor
from ww_config import get_settings
def configure_logging() -> None:
settings = get_settings()
shared: list[Processor] = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
]
renderer = (
structlog.processors.JSONRenderer()
if settings.log_json
else structlog.dev.ConsoleRenderer()
)
processors: list[Processor] = [*shared, renderer]
structlog.configure(
processors=processors,
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
cache_logger_on_first_use=True,
)
def get_logger(name: str = "ww") -> structlog.stdlib.BoundLogger:
logger: structlog.stdlib.BoundLogger = structlog.get_logger(name)
return logger

55
apps/api/ww_api/main.py Normal file
View File

@@ -0,0 +1,55 @@
"""FastAPI 应用入口ARCH §7"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from ww_db import get_sessionmaker
from ww_shared import AppError, ErrorBody, ErrorEnvelope
from ww_api.logging_config import configure_logging, get_logger
from ww_api.middleware import request_id_middleware
from ww_api.routers import health, jobs, projects, settings_providers
from ww_api.services.project_deps import seed_stub_user
configure_logging()
log = get_logger("ww.api")
@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
# 幂等 seed 单用户 stub——所有 owner_id FK 依赖它(见 memory/gotchas
async with get_sessionmaker()() as session:
await seed_stub_user(session)
yield
def create_app() -> FastAPI:
app = FastAPI(title="网文创作工作流 API", version="0.0.0", lifespan=_lifespan)
app.middleware("http")(request_id_middleware)
@app.exception_handler(AppError)
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:
request_id = getattr(request.state, "request_id", None)
log.warning("app_error", code=exc.code, message=exc.message)
envelope = ErrorEnvelope(
error=ErrorBody(
code=exc.code,
message=exc.message,
details=exc.details,
request_id=request_id,
)
)
return JSONResponse(status_code=exc.http_status, content=envelope.model_dump())
app.include_router(health.router)
app.include_router(jobs.router)
app.include_router(projects.router)
app.include_router(settings_providers.router)
return app
app = create_app()

View File

@@ -0,0 +1,28 @@
"""request_id 关联中间件ARCH §9.3 / CLAUDE.md
每请求生成/透传 request_id绑入 structlog contextvars并回写响应头
错误信封也带上同一 id便于端到端 grep。
"""
from __future__ import annotations
import uuid
from collections.abc import Awaitable, Callable
import structlog
from starlette.requests import Request
from starlette.responses import Response
REQUEST_ID_HEADER = "x-request-id"
async def request_id_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
request_id = request.headers.get(REQUEST_ID_HEADER) or uuid.uuid4().hex
structlog.contextvars.clear_contextvars()
structlog.contextvars.bind_contextvars(request_id=request_id)
request.state.request_id = request_id
response = await call_next(request)
response.headers[REQUEST_ID_HEADER] = request_id
return response

View File

View File

@@ -0,0 +1,17 @@
"""根路由 + 健康检查。"""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter()
@router.get("/")
async def root() -> dict[str, str]:
return {"service": "ww-api", "status": "ok"}
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}

View File

@@ -0,0 +1,33 @@
"""长任务轮询端点 GET /jobs/:idARCH §7.4)。"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db import get_session
from ww_db.models import Job
from ww_shared import AppError, ErrorCode
router = APIRouter(prefix="/jobs", tags=["jobs"])
@router.get("/{job_id}")
async def get_job(
job_id: uuid.UUID,
session: Annotated[AsyncSession, Depends(get_session)],
) -> dict[str, object]:
job = (await session.execute(select(Job).where(Job.id == job_id))).scalar_one_or_none()
if job is None:
raise AppError(ErrorCode.NOT_FOUND, f"job {job_id} not found")
return {
"id": str(job.id),
"kind": job.kind,
"status": job.status,
"progress": job.progress,
"result": job.result,
"error": job.error,
}

View File

@@ -0,0 +1 @@
"""API 边界 Pydantic schemassnake_case前端经 OpenAPI 消费)。"""

View File

@@ -0,0 +1 @@
"""安全工具凭据加解密与掩码ARCH §4.7)。"""

View File

@@ -0,0 +1 @@
"""API 服务层:凭据存取与提供商探测(依赖接口,便于测试注入替身)。"""

1
apps/web/.eslintrc.json Normal file
View File

@@ -0,0 +1 @@
{ "extends": "next/core-web-vitals" }

4
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/node_modules
/.next
/lib/api/openapi.json
next-env.d.ts

15
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-slim AS build
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile || pnpm install
COPY . .
RUN pnpm build
FROM node:22-slim AS run
WORKDIR /app
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

50
apps/web/app/globals.css Normal file
View File

@@ -0,0 +1,50 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 纸感设计 tokenUX_SPEC §2.1 */
:root {
--color-bg: #f5f1e8;
--color-panel: #fbf8f1;
--color-ink: #2b2620;
--color-ink-soft: #6b6356;
--color-line: #e5ddcd;
--color-cinnabar: #a23b2e;
--color-cinnabar-wash: #a23b2e14;
--color-conflict: #b5543a;
--color-overdue: #c8893a;
--color-pass: #5a6b4f;
--color-info: #4a5a6b;
}
body {
background: var(--color-bg);
color: var(--color-ink);
}
/* 流式打字机光标:朱砂闪烁;尊重 prefers-reduced-motionUX §10。 */
.typewriter-cursor {
animation: typewriter-blink 1s step-end infinite;
}
@keyframes typewriter-blink {
50% {
opacity: 0;
}
}
/* 冲突就地标注锚点朱砂波浪下划线UX §8.3)。 */
.conflict-anchor {
text-decoration: underline wavy var(--color-conflict);
text-underline-offset: 3px;
transition: background-color 0.15s ease;
}
@media (prefers-reduced-motion: reduce) {
.typewriter-cursor {
animation: none;
}
.conflict-anchor {
transition: none;
}
}

23
apps/web/app/layout.tsx Normal file
View File

@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import "./globals.css";
import { ToastProvider } from "@/components/Toast";
export const metadata: Metadata = {
title: "网文创作工作流",
description: "把写小说当软件工程:架构→生成→质检→单一真相源",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body className="font-sans antialiased">
<ToastProvider>{children}</ToastProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,46 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { LeftNav } from "./LeftNav";
interface AppShellProps {
children: ReactNode;
// 顶栏右侧的上下文信息(如作品名 / 进度)。
title?: string;
subtitle?: string;
}
// 全局外壳:顶栏(64px) + 左导航 + 主区UX §5.1)。
export function AppShell({ children, title, subtitle }: AppShellProps) {
return (
<div className="min-h-screen">
<header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-6">
<Link
href="/"
className="font-serif text-xl text-cinnabar"
aria-label="返回作品库"
>
</Link>
{title ? (
<span className="font-serif text-lg text-ink">{title}</span>
) : null}
{subtitle ? (
<span className="font-mono text-xs text-ink-soft">{subtitle}</span>
) : null}
<nav className="ml-auto flex items-center gap-4 text-sm">
<Link
href="/settings/providers"
className="text-ink-soft hover:text-cinnabar"
>
</Link>
</nav>
</header>
<div className="flex">
<LeftNav />
<main className="min-h-[calc(100vh-4rem)] flex-1 bg-bg">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface NavItem {
href: string;
label: string;
// M1 仅作品库 + 设置可达;其余为后续里程碑占位(禁用)。
enabled: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ href: "/", label: "作品库", enabled: true },
{ href: "/settings/providers", label: "设置", enabled: true },
{ href: "#outline", label: "大纲", enabled: false },
{ href: "#codex", label: "设定库", enabled: false },
{ href: "#foreshadow", label: "伏笔", enabled: false },
{ href: "#review", label: "审稿", enabled: false },
{ href: "#style", label: "文风", enabled: false },
];
// 左导航:当前项朱砂竖条 + 浅晕底UX §5.1)。
export function LeftNav() {
const pathname = usePathname();
return (
<nav
className="w-44 shrink-0 border-r border-line bg-panel py-4"
aria-label="主导航"
>
<ul className="flex flex-col gap-1 px-2">
{NAV_ITEMS.map((item) => {
const active = item.enabled && pathname === item.href;
if (!item.enabled) {
return (
<li key={item.label}>
<span
className="block cursor-not-allowed rounded px-3 py-2 text-sm text-ink-soft/50"
aria-disabled="true"
title="后续里程碑开放"
>
{item.label}
</span>
</li>
);
}
return (
<li key={item.label}>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${
active
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "text-ink hover:bg-[var(--color-cinnabar-wash)]"
}`}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
type ToastKind = "info" | "error" | "success";
interface ToastItem {
id: number;
message: string;
kind: ToastKind;
}
type ShowToast = (message: string, kind?: ToastKind) => void;
const ToastContext = createContext<ShowToast | null>(null);
const TOAST_TTL_MS = 4000;
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const show = useCallback<ShowToast>((message, kind = "info") => {
const id = Date.now() + Math.random();
setItems((prev) => [...prev, { id, message, kind }]);
setTimeout(() => {
setItems((prev) => prev.filter((t) => t.id !== id));
}, TOAST_TTL_MS);
}, []);
const value = useMemo(() => show, [show]);
return (
<ToastContext.Provider value={value}>
{children}
<div
className="pointer-events-none fixed bottom-6 right-6 z-50 flex flex-col gap-2"
aria-live="polite"
role="status"
>
{items.map((t) => (
<div
key={t.id}
className={`pointer-events-auto rounded border px-4 py-2 text-sm shadow-paper ${
t.kind === "error"
? "border-conflict bg-panel text-conflict"
: t.kind === "success"
? "border-pass bg-panel text-pass"
: "border-line bg-panel text-ink"
}`}
>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ShowToast {
const ctx = useContext(ToastContext);
if (!ctx) {
// 容错:未挂 Provider 时退化为 no-op不应在生产路径发生
return () => undefined;
}
return ctx;
}

View File

@@ -0,0 +1,8 @@
import { describe, expect, it } from "vitest";
describe("api base url", () => {
it("falls back to localhost when env unset", () => {
const base = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
expect(base).toMatch(/^https?:\/\//);
});
});

View File

@@ -0,0 +1,8 @@
import createClient from "openapi-fetch";
import type { paths } from "./schema";
import { API_BASE_PUBLIC } from "./config";
// 类型安全的后端客户端schema.d.ts 由 `pnpm gen:api` 从后端 OpenAPI 生成)。
// 浏览器侧使用 NEXT_PUBLIC_API_BASE服务端读取请走 lib/api/server.ts。
export const api = createClient<paths>({ baseUrl: API_BASE_PUBLIC });

View File

@@ -0,0 +1,8 @@
// 后端基址:服务端组件用 INTERNAL容器内 service 名),客户端用 PUBLIC浏览器可达
// 两者皆可缺省回退到本地开发地址。
export const API_BASE_PUBLIC =
process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
export function serverApiBase(): string {
return process.env.API_BASE_INTERNAL ?? API_BASE_PUBLIC;
}

987
apps/web/lib/api/schema.d.ts vendored Normal file
View File

@@ -0,0 +1,987 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Root */
get: operations["root__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/health": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Health */
get: operations["health_health_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/jobs/{job_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Job */
get: operations["get_job_jobs__job_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Projects */
get: operations["list_projects_projects_get"];
put?: never;
/** Create Project */
post: operations["create_project_projects_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Project */
get: operations["get_project_projects__project_id__get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/draft": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Save Draft
* @description 自动保存:幂等 upsert 草稿(同章节覆盖同一行,版次不爆炸)。
*/
put: operations["save_draft_projects__project_id__chapters__chapter_no__draft_put"];
/**
* Stream Draft
* @description 流式写章草稿:组装记忆 → 网关流 → 归一为 SSE 事件 → text/event-stream。
*/
post: operations["stream_draft_projects__project_id__chapters__chapter_no__draft_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/review": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Review Chapter
* @description 续审SSE组审稿上下文 → 跑审稿子图 → 归一为 section/conflict/done 事件。
*
* 提交边界:网关 ledger + collect 经 review_repo.record 均只 flush端点在**流耗尽后**
* `await session.commit()`(镜像 draft 端点,否则记账/留痕静默丢失)。
*/
post: operations["review_chapter_projects__project_id__chapters__chapter_no__review_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/reviews": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Reviews
* @description 审稿历史(新→旧):供前端审稿页加载既往审稿留痕 + 裁决。
*/
get: operations["list_reviews_projects__project_id__chapters__chapter_no__reviews_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/chapters/{chapter_no}/accept": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Accept Chapter
* @description 验收事务 + 冲突 gate§5.5gate事务前→ 终稿提炼 digest事务外R2
* 单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit。
*/
post: operations["accept_chapter_projects__project_id__chapters__chapter_no__accept_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/settings/providers": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Providers */
get: operations["list_providers_settings_providers_get"];
/** Upsert Providers */
put: operations["upsert_providers_settings_providers_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/settings/providers/test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Test Connection */
post: operations["test_connection_settings_providers_test_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/**
* AcceptRequest
* @description POST /projects/:id/chapters/:no/accept裁决清单 + 可能改过的终稿。
*/
AcceptRequest: {
/**
* Final Text
* @description 作者裁决/改稿后的最终验收文本
*/
final_text: string;
/**
* Decisions
* @description 对最近审稿每个冲突的裁决每冲突必有其一R5
*/
decisions?: components["schemas"]["ConflictDecision"][];
};
/**
* AcceptResponse
* @description 验收「本次将更新」清单ARCH §7.2 写回结果)。
*/
AcceptResponse: {
/**
* Project Id
* Format: uuid
*/
project_id: string;
/** Chapter No */
chapter_no: number;
/**
* Accepted Version
* @description 晋升到的 accepted 版次max+1
*/
accepted_version: number;
/**
* Digest Added
* @description 是否新增了一行 chapter_digests
*/
digest_added: boolean;
/**
* Decisions Recorded
* @description 本次写回的裁决条数
*/
decisions_recorded: number;
/**
* Review Id
* @description 写回裁决的审稿留痕行 id
*/
review_id?: string | null;
};
/**
* CapabilitiesView
* @description 探测得到的能力矩阵(镜像网关 `Capabilities`)。
*/
CapabilitiesView: {
/**
* Structured Output
* @default false
*/
structured_output: boolean;
/**
* Prefix Cache
* @default false
*/
prefix_cache: boolean;
/**
* Thinking
* @default false
*/
thinking: boolean;
};
/**
* ConflictDecision
* @description 对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。
*/
ConflictDecision: {
/**
* Conflict Index
* @description 冲突在最近审稿 conflicts 列表中的下标
*/
conflict_index: number;
/**
* Verdict
* @description 采纳改法 / 忽略 / 手改
* @enum {string}
*/
verdict: "accept" | "ignore" | "manual";
/**
* Note
* @description 可选裁决备注(如手改说明)
*/
note?: string | null;
};
/**
* DraftResponse
* @description 草稿保存结果(脱敏:只回元信息 + 长度,不回灌正文以外的衍生)。
*/
DraftResponse: {
/**
* Project Id
* Format: uuid
*/
project_id: string;
/** Chapter No */
chapter_no: number;
/** Volume */
volume: number;
/** Status */
status: string;
/** Version */
version: number;
/** Length */
length: number;
};
/**
* DraftSaveRequest
* @description PUT /projects/:id/chapters/:no/draft自动保存草稿正文。
*/
DraftSaveRequest: {
/** Text */
text: string;
};
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/**
* ProjectCreateRequest
* @description POST /projects立项向导字段owner_id 由后端补 stub不入参
*/
ProjectCreateRequest: {
/** Title */
title: string;
/** Genre */
genre?: string | null;
/** Logline */
logline?: string | null;
/** Premise */
premise?: string | null;
/** Theme */
theme?: string | null;
/** Selling Points */
selling_points?: unknown[];
/** Structure */
structure?: string | null;
};
/**
* ProjectListResponse
* @description GET /projects项目列表。
*/
ProjectListResponse: {
/** Projects */
projects?: components["schemas"]["ProjectResponse"][];
};
/**
* ProjectResponse
* @description 项目视图(创建/列表/详情共用)。
*/
ProjectResponse: {
/**
* Id
* Format: uuid
*/
id: string;
/** Title */
title: string;
/** Genre */
genre?: string | null;
/** Logline */
logline?: string | null;
/** Premise */
premise?: string | null;
/** Theme */
theme?: string | null;
/** Selling Points */
selling_points?: unknown[];
/** Structure */
structure?: string | null;
};
/**
* ProviderCredentialInput
* @description 单条提供商凭据写入。
*/
ProviderCredentialInput: {
/** Provider */
provider: string;
/** Api Key */
api_key: string;
};
/**
* ProviderView
* @description 已配置提供商(掩码视图)。
*/
ProviderView: {
/** Provider */
provider: string;
/** Masked Key */
masked_key: string;
};
/**
* ProvidersResponse
* @description GET/PUT 响应:已配置提供商(掩码)+ 当前档位路由。
*/
ProvidersResponse: {
/** Providers */
providers?: components["schemas"]["ProviderView"][];
/** Tier Routing */
tier_routing?: components["schemas"]["TierRoutingView"][];
};
/**
* ProvidersUpsertRequest
* @description PUT 请求:可同时 upsert 若干凭据与档位路由(幂等)。
*/
ProvidersUpsertRequest: {
/** Credentials */
credentials?: components["schemas"]["ProviderCredentialInput"][];
/** Tier Routing */
tier_routing?: components["schemas"]["TierRoutingInput"][];
};
/**
* ReviewHistoryItem
* @description 单条审稿留痕GET .../reviews 历史项snake_case
*/
ReviewHistoryItem: {
/**
* Id
* Format: uuid
*/
id: string;
/**
* Project Id
* Format: uuid
*/
project_id: string;
/** Chapter No */
chapter_no: number;
/** Chapter Version */
chapter_version?: number | null;
/** Conflicts */
conflicts?: {
[key: string]: unknown;
}[];
/** Foreshadow Sug */
foreshadow_sug?: {
[key: string]: unknown;
}[];
/** Style */
style?: {
[key: string]: unknown;
} | null;
/** Pace */
pace?: {
[key: string]: unknown;
} | null;
/** Health Score */
health_score?: number | null;
/** Decisions */
decisions?: {
[key: string]: unknown;
} | null;
};
/**
* ReviewHistoryResponse
* @description GET /projects/:id/chapters/:no/reviews审稿历史新→旧
*/
ReviewHistoryResponse: {
/** Reviews */
reviews?: components["schemas"]["ReviewHistoryItem"][];
};
/**
* ReviewRequest
* @description POST /projects/:id/chapters/:no/review可选携带待审草稿正文。
*
* 不传 `draft` 时端点回退到已保存的草稿chapter_repo
*/
ReviewRequest: {
/** Draft */
draft?: string | null;
};
/**
* TestConnectionRequest
* @description POST /test最小探测请求。
*/
TestConnectionRequest: {
/** Provider */
provider: string;
};
/**
* TestConnectionResponse
* @description POST /test 响应:连通性 + 能力矩阵。
*/
TestConnectionResponse: {
/** Provider */
provider: string;
/** Ok */
ok: boolean;
capabilities: components["schemas"]["CapabilitiesView"];
};
/**
* TierRoutingInput
* @description 单条档位路由写入。
*/
TierRoutingInput: {
/** Tier */
tier: string;
/** Provider */
provider: string;
/** Model */
model: string;
/** Fallback */
fallback?: string[];
};
/**
* TierRoutingView
* @description 档位 → provider:model 路由(含回退链)。
*/
TierRoutingView: {
/** Tier */
tier: string;
/** Provider */
provider: string;
/** Model */
model: string;
/** Fallback */
fallback?: string[];
};
/** ValidationError */
ValidationError: {
/** Location */
loc: (string | number)[];
/** Message */
msg: string;
/** Error Type */
type: string;
/** Input */
input?: unknown;
/** Context */
ctx?: Record<string, never>;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
root__get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
health_health_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
get_job_jobs__job_id__get: {
parameters: {
query?: never;
header?: never;
path: {
job_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_projects_projects_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectListResponse"];
};
};
};
};
create_project_projects_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ProjectCreateRequest"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_project_projects__project_id__get: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
save_draft_projects__project_id__chapters__chapter_no__draft_put: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DraftSaveRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DraftResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
stream_draft_projects__project_id__chapters__chapter_no__draft_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
review_chapter_projects__project_id__chapters__chapter_no__review_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ReviewRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_reviews_projects__project_id__chapters__chapter_no__reviews_get: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReviewHistoryResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
accept_chapter_projects__project_id__chapters__chapter_no__accept_post: {
parameters: {
query?: never;
header?: never;
path: {
project_id: string;
chapter_no: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AcceptRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AcceptResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_providers_settings_providers_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProvidersResponse"];
};
};
};
};
upsert_providers_settings_providers_put: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ProvidersUpsertRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProvidersResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
test_connection_settings_providers_test_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TestConnectionRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TestConnectionResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}

View File

@@ -0,0 +1,37 @@
import { serverApiBase } from "./config";
import type {
ProjectListResponse,
ProjectResponse,
ProvidersResponse,
ReviewHistoryResponse,
} from "./types";
// 服务端只读取数据Server Components。失败时抛出由页面边界处理。
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(`${serverApiBase()}${path}`, { cache: "no-store" });
if (!res.ok) {
throw new Error(`请求失败 ${res.status}: ${path}`);
}
return (await res.json()) as T;
}
export async function fetchProjects(): Promise<ProjectListResponse> {
return getJson<ProjectListResponse>("/projects");
}
export async function fetchProject(projectId: string): Promise<ProjectResponse> {
return getJson<ProjectResponse>(`/projects/${projectId}`);
}
export async function fetchProviders(): Promise<ProvidersResponse> {
return getJson<ProvidersResponse>("/settings/providers");
}
export async function fetchReviews(
projectId: string,
chapterNo: number,
): Promise<ReviewHistoryResponse> {
return getJson<ReviewHistoryResponse>(
`/projects/${projectId}/chapters/${chapterNo}/reviews`,
);
}

27
apps/web/lib/api/types.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { components } from "./schema";
// 复用后端 OpenAPI 生成的 schema 类型绝不手写共享类型CLAUDE.md 命名契约)。
export type ProjectResponse = components["schemas"]["ProjectResponse"];
export type ProjectCreateRequest = components["schemas"]["ProjectCreateRequest"];
export type ProjectListResponse = components["schemas"]["ProjectListResponse"];
export type DraftSaveRequest = components["schemas"]["DraftSaveRequest"];
export type DraftResponse = components["schemas"]["DraftResponse"];
export type ProvidersResponse = components["schemas"]["ProvidersResponse"];
export type ProviderView = components["schemas"]["ProviderView"];
export type TierRoutingView = components["schemas"]["TierRoutingView"];
export type ProvidersUpsertRequest =
components["schemas"]["ProvidersUpsertRequest"];
export type TestConnectionRequest =
components["schemas"]["TestConnectionRequest"];
export type TestConnectionResponse =
components["schemas"]["TestConnectionResponse"];
export type CapabilitiesView = components["schemas"]["CapabilitiesView"];
// M2 审/裁/验收C3 扩)。
export type ReviewRequest = components["schemas"]["ReviewRequest"];
export type ReviewHistoryItem = components["schemas"]["ReviewHistoryItem"];
export type ReviewHistoryResponse =
components["schemas"]["ReviewHistoryResponse"];
export type ConflictDecision = components["schemas"]["ConflictDecision"];
export type AcceptRequest = components["schemas"]["AcceptRequest"];
export type AcceptResponse = components["schemas"]["AcceptResponse"];

5
apps/web/next.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
export default nextConfig;

33
apps/web/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "web",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"gen:api": "node scripts/gen-api.mjs"
},
"dependencies": {
"next": "15.1.3",
"openapi-fetch": "^0.13.0",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "19.0.0",
"@types/react-dom": "19.0.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-next": "15.1.3",
"openapi-typescript": "^7.5.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}

4906
apps/web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
packages:
- .
allowBuilds:
esbuild: set this to true or false
sharp: set this to true or false
unrs-resolver: set this to true or false
onlyBuiltDependencies:
- esbuild
- sharp
- unrs-resolver
verifyDepsBeforeRun: false

View File

@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
};

View File

@@ -0,0 +1,26 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const repoRoot = resolve(import.meta.dirname, "../../..");
const webRoot = resolve(import.meta.dirname, "..");
const outDir = resolve(webRoot, "lib/api");
const jsonPath = resolve(outDir, "openapi.json");
const dtsPath = resolve(outDir, "schema.d.ts");
mkdirSync(outDir, { recursive: true });
// 1) 后端 OpenAPIuv 运行离线CI 无需起服务)
const uv = process.env.UV_BIN ?? "uv";
const spec = execFileSync(uv, ["run", "python", "-m", "ww_api.export_openapi"], {
cwd: repoRoot,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
});
writeFileSync(jsonPath, spec);
// 2) OpenAPI → TS 类型(本地 bin避免 pnpm exec 的工作区检查)
const bin = resolve(webRoot, "node_modules/.bin/openapi-typescript");
execFileSync(bin, [jsonPath, "-o", dtsPath], { cwd: webRoot, stdio: "inherit" });
console.log("gen:api done ->", dtsPath);

View File

@@ -0,0 +1,32 @@
import type { Config } from "tailwindcss";
// 纸感·文学温暖主题UX_SPEC §2。颜色经 CSS 变量落地,便于后续夜读模式切换。
const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
bg: "var(--color-bg)",
panel: "var(--color-panel)",
ink: "var(--color-ink)",
"ink-soft": "var(--color-ink-soft)",
line: "var(--color-line)",
cinnabar: "var(--color-cinnabar)",
conflict: "var(--color-conflict)",
overdue: "var(--color-overdue)",
pass: "var(--color-pass)",
info: "var(--color-info)",
},
fontFamily: {
serif: ['"Noto Serif SC"', '"Songti SC"', "serif"],
sans: ['"Noto Sans SC"', '"PingFang SC"', "system-ui", "sans-serif"],
mono: ['"JetBrains Mono"', "ui-monospace"],
},
borderRadius: { DEFAULT: "6px" },
boxShadow: { paper: "0 1px 3px #2B26200F" },
maxWidth: { prose: "720px" },
},
},
plugins: [],
};
export default config;

21
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

File diff suppressed because one or more lines are too long

14
apps/web/vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { resolve } from "node:path";
import { defineConfig } from "vitest/config";
// 单测纯逻辑SSE reducer / 自动保存防抖 / 向导流程node 环境,无需 DOM。
export default defineConfig({
resolve: {
alias: { "@": resolve(__dirname, ".") },
},
test: {
environment: "node",
include: ["lib/**/*.test.ts"],
},
});

43
docker-compose.yml Normal file
View File

@@ -0,0 +1,43 @@
services:
pg:
image: postgres:16-alpine
environment:
POSTGRES_USER: writer
POSTGRES_PASSWORD: writer
POSTGRES_DB: writer
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U writer"]
interval: 5s
timeout: 3s
retries: 10
volumes:
- pgdata:/var/lib/postgresql/data
api:
build:
context: .
dockerfile: apps/api/Dockerfile
environment:
DATABASE_URL: postgresql+asyncpg://writer:writer@pg:5432/writer
DATABASE_URL_SYNC: postgresql+psycopg://writer:writer@pg:5432/writer
APP_ENV: dev
ports:
- "8000:8000"
depends_on:
pg:
condition: service_healthy
web:
build:
context: ./apps/web
environment:
NEXT_PUBLIC_API_BASE: http://localhost:8000
ports:
- "3000:3000"
depends_on:
- api
volumes:
pgdata:

View File

@@ -0,0 +1,15 @@
[project]
name = "ww-config"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = ["pydantic>=2.7", "pydantic-settings>=2.3", "ww-shared"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["ww_config"]
[tool.uv.sources]
ww-shared = { workspace = true }

View File

@@ -0,0 +1,3 @@
from ww_config.settings import Settings, get_settings
__all__ = ["Settings", "get_settings"]

View File

@@ -0,0 +1,29 @@
"""应用配置env 解析)。提供商注册/档位默认见后续阶段。"""
from __future__ import annotations
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_env: str = "dev"
log_json: bool = False
database_url: str = "postgresql+asyncpg://writer:writer@localhost:5432/writer"
database_url_sync: str = "postgresql+psycopg://writer:writer@localhost:5432/writer"
credential_enc_key: str = ""
# 档位默认writer/analyst/light——具体 provider:model 经 tier_routing 覆盖
tier_defaults: dict[str, str] = {
"writer": "deepseek:deepseek-chat",
"analyst": "deepseek:deepseek-chat",
"light": "deepseek:deepseek-chat",
}
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,54 @@
"""Alembic async envCLAUDE.mdcheckpointer.setup 在迁移/CI此处仅领域表"""
from __future__ import annotations
import asyncio
from logging.config import fileConfig
import ww_db.models # noqa: F401 注册所有表到 metadata
from alembic import context
from sqlalchemy import Connection, pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from ww_config import get_settings
from ww_db.base import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 用 async URLasyncpg运行迁移
config.set_main_option("sqlalchemy.url", get_settings().database_url)
target_metadata = Base.metadata
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_async_migrations())

View File

@@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

View File

@@ -0,0 +1,366 @@
"""initial schema 16 MVP tables
Revision ID: 220ca2e3d53f
Revises:
Create Date: 2026-06-18 07:55:55.972365
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "220ca2e3d53f"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"users",
sa.Column("email", sa.Text(), nullable=False),
sa.Column("display_name", sa.Text(), nullable=True),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_table(
"projects",
sa.Column("owner_id", sa.Uuid(), nullable=False),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("genre", sa.Text(), nullable=True),
sa.Column("logline", sa.Text(), nullable=True),
sa.Column("premise", sa.Text(), nullable=True),
sa.Column("theme", sa.Text(), nullable=True),
sa.Column(
"selling_points",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("structure", sa.Text(), nullable=True),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["owner_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"skills",
sa.Column("scope", sa.Text(), nullable=False),
sa.Column("owner_id", sa.Uuid(), nullable=True),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("tier", sa.Text(), nullable=False),
sa.Column("system_prompt", sa.Text(), nullable=False),
sa.Column("input_schema", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("output_schema", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column(
"reads",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column(
"writes",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("genre", sa.Text(), nullable=True),
sa.Column(
"examples",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["owner_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"chapter_digests",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("chapter_no", sa.Integer(), nullable=False),
sa.Column("facts", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_chapter_digests_project_id"), "chapter_digests", ["project_id"], unique=False
)
op.create_table(
"chapter_reviews",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("chapter_no", sa.Integer(), nullable=False),
sa.Column("chapter_version", sa.Integer(), nullable=True),
sa.Column(
"conflicts",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column(
"foreshadow_sug",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("style", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("pace", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("health_score", sa.Integer(), nullable=True),
sa.Column("decisions", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_chapter_reviews_project_id"), "chapter_reviews", ["project_id"], unique=False
)
op.create_table(
"chapters",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("volume", sa.Integer(), nullable=False),
sa.Column("chapter_no", sa.Integer(), nullable=False),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("status", sa.Text(), server_default=sa.text("'draft'"), nullable=False),
sa.Column("version", sa.Integer(), server_default=sa.text("1"), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("project_id", "chapter_no", "version"),
)
op.create_index(op.f("ix_chapters_project_id"), "chapters", ["project_id"], unique=False)
op.create_table(
"characters",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("role", sa.Text(), nullable=True),
sa.Column("traits", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("appearance", sa.Text(), nullable=True),
sa.Column("motive", sa.Text(), nullable=True),
sa.Column("backstory", sa.Text(), nullable=True),
sa.Column("arc", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("speech_tics", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column(
"tags",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column(
"relations",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("first_chapter", sa.Integer(), nullable=True),
sa.Column("latest_state", sa.Text(), nullable=True),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_characters_project_id"), "characters", ["project_id"], unique=False)
op.create_table(
"foreshadow",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("code", sa.Text(), nullable=False),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), server_default=sa.text("'OPEN'"), nullable=False),
sa.Column("planted_at", sa.Integer(), nullable=True),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("expected_close_from", sa.Integer(), nullable=True),
sa.Column("expected_close_to", sa.Integer(), nullable=True),
sa.Column("importance", sa.Text(), nullable=True),
sa.Column(
"links",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column(
"progress",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("project_id", "code"),
)
op.create_index(op.f("ix_foreshadow_project_id"), "foreshadow", ["project_id"], unique=False)
op.create_table(
"jobs",
sa.Column("project_id", sa.Uuid(), nullable=True),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), server_default=sa.text("'queued'"), nullable=False),
sa.Column("progress", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("result", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"outline",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("volume", sa.Integer(), nullable=False),
sa.Column("chapter_no", sa.Integer(), nullable=False),
sa.Column("beats", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column(
"foreshadow_windows",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("project_id", "chapter_no"),
)
op.create_index(op.f("ix_outline_project_id"), "outline", ["project_id"], unique=False)
op.create_table(
"provider_credentials",
sa.Column("owner_id", sa.Uuid(), nullable=False),
sa.Column("project_id", sa.Uuid(), nullable=True),
sa.Column("provider", sa.Text(), nullable=False),
sa.Column("api_key_enc", sa.LargeBinary(), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("owner_id", "project_id", "provider"),
)
op.create_table(
"rules",
sa.Column("project_id", sa.Uuid(), nullable=True),
sa.Column("level", sa.Text(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"style_fingerprint",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("dimensions_json", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("evidence_json", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("version", sa.Integer(), server_default=sa.text("1"), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_style_fingerprint_project_id"), "style_fingerprint", ["project_id"], unique=False
)
op.create_table(
"tier_routing",
sa.Column("project_id", sa.Uuid(), nullable=True),
sa.Column("tier", sa.Text(), nullable=False),
sa.Column("provider", sa.Text(), nullable=False),
sa.Column("model", sa.Text(), nullable=False),
sa.Column(
"fallback",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'[]'"),
nullable=False,
),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("project_id", "tier"),
)
op.create_table(
"usage_ledger",
sa.Column("owner_id", sa.Uuid(), nullable=False),
sa.Column("project_id", sa.Uuid(), nullable=True),
sa.Column("provider", sa.Text(), nullable=False),
sa.Column("model", sa.Text(), nullable=False),
sa.Column("input_tokens", sa.Integer(), nullable=False),
sa.Column("output_tokens", sa.Integer(), nullable=False),
sa.Column("cache_read", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("cost_minor", sa.BigInteger(), nullable=False),
sa.Column("currency", sa.Text(), nullable=False),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["owner_id"],
["users.id"],
),
sa.ForeignKeyConstraint(
["project_id"],
["projects.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"world_entities",
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("type", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("rules", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("first_chapter", sa.Integer(), nullable=True),
sa.Column("latest_state", sa.Text(), nullable=True),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_world_entities_project_id"), "world_entities", ["project_id"], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_world_entities_project_id"), table_name="world_entities")
op.drop_table("world_entities")
op.drop_table("usage_ledger")
op.drop_table("tier_routing")
op.drop_index(op.f("ix_style_fingerprint_project_id"), table_name="style_fingerprint")
op.drop_table("style_fingerprint")
op.drop_table("rules")
op.drop_table("provider_credentials")
op.drop_index(op.f("ix_outline_project_id"), table_name="outline")
op.drop_table("outline")
op.drop_table("jobs")
op.drop_index(op.f("ix_foreshadow_project_id"), table_name="foreshadow")
op.drop_table("foreshadow")
op.drop_index(op.f("ix_characters_project_id"), table_name="characters")
op.drop_table("characters")
op.drop_index(op.f("ix_chapters_project_id"), table_name="chapters")
op.drop_table("chapters")
op.drop_index(op.f("ix_chapter_reviews_project_id"), table_name="chapter_reviews")
op.drop_table("chapter_reviews")
op.drop_index(op.f("ix_chapter_digests_project_id"), table_name="chapter_digests")
op.drop_table("chapter_digests")
op.drop_table("skills")
op.drop_table("projects")
op.drop_table("users")
# ### end Alembic commands ###

View File

@@ -0,0 +1,23 @@
[project]
name = "ww-db"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = [
"sqlalchemy[asyncio]>=2.0",
"asyncpg>=0.29",
"psycopg[binary]>=3.2",
"alembic>=1.13",
"ww-shared",
"ww-config",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["ww_db"]
[tool.uv.sources]
ww-shared = { workspace = true }
ww-config = { workspace = true }

View File

@@ -0,0 +1,4 @@
from ww_db.base import Base
from ww_db.session import get_session, get_sessionmaker
__all__ = ["Base", "get_session", "get_sessionmaker"]

29
packages/db/ww_db/base.py Normal file
View File

@@ -0,0 +1,29 @@
"""SQLAlchemy 2.0 声明式基类 + 通用 mixin。"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Uuid, func, text
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class UuidPk:
id: Mapped[uuid.UUID] = mapped_column(
Uuid, primary_key=True, server_default=text("gen_random_uuid()")
)
class CreatedAt:
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
class TimestampedMixin(CreatedAt):
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(), onupdate=func.now(), nullable=False
)

236
packages/db/ww_db/models.py Normal file
View File

@@ -0,0 +1,236 @@
"""ORM 模型——全部 MVP 表ARCH §3.1)。
snake_case无向量列P2 表 timeline/decisions 不建(见 DEV_PLAN T0.2)。
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
BigInteger,
ForeignKey,
Integer,
LargeBinary,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from ww_db.base import Base, CreatedAt, TimestampedMixin, UuidPk
# ---- 运营/系统表 ----
class User(UuidPk, CreatedAt, Base):
__tablename__ = "users"
email: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(Text)
# ---- 创作数据表 ----
class Project(UuidPk, TimestampedMixin, Base):
__tablename__ = "projects"
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
title: Mapped[str] = mapped_column(Text, nullable=False)
genre: Mapped[str | None] = mapped_column(Text)
logline: Mapped[str | None] = mapped_column(Text)
premise: Mapped[str | None] = mapped_column(Text)
theme: Mapped[str | None] = mapped_column(Text)
selling_points: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
structure: Mapped[str | None] = mapped_column(Text)
class Character(UuidPk, CreatedAt, Base):
__tablename__ = "characters"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str | None] = mapped_column(Text)
traits: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
appearance: Mapped[str | None] = mapped_column(Text)
motive: Mapped[str | None] = mapped_column(Text)
backstory: Mapped[str | None] = mapped_column(Text)
arc: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
speech_tics: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
tags: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
relations: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
first_chapter: Mapped[int | None] = mapped_column(Integer)
latest_state: Mapped[str | None] = mapped_column(Text)
class WorldEntity(UuidPk, CreatedAt, Base):
__tablename__ = "world_entities"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
type: Mapped[str] = mapped_column(Text, nullable=False)
name: Mapped[str] = mapped_column(Text, nullable=False)
rules: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
first_chapter: Mapped[int | None] = mapped_column(Integer)
latest_state: Mapped[str | None] = mapped_column(Text)
class Outline(UuidPk, Base):
__tablename__ = "outline"
__table_args__ = (UniqueConstraint("project_id", "chapter_no"),)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
volume: Mapped[int] = mapped_column(Integer, nullable=False)
chapter_no: Mapped[int] = mapped_column(Integer, nullable=False)
beats: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
foreshadow_windows: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
class ChapterDigest(UuidPk, CreatedAt, Base):
__tablename__ = "chapter_digests"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
chapter_no: Mapped[int] = mapped_column(Integer, nullable=False)
facts: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
class Foreshadow(UuidPk, Base):
__tablename__ = "foreshadow"
__table_args__ = (UniqueConstraint("project_id", "code"),)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
code: Mapped[str] = mapped_column(Text, nullable=False)
title: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'OPEN'"))
planted_at: Mapped[int | None] = mapped_column(Integer)
content: Mapped[str | None] = mapped_column(Text)
expected_close_from: Mapped[int | None] = mapped_column(Integer)
expected_close_to: Mapped[int | None] = mapped_column(Integer)
importance: Mapped[str | None] = mapped_column(Text)
links: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
progress: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
updated_at: Mapped[datetime] = mapped_column(
nullable=False, server_default=text("now()"), onupdate=text("now()")
)
class StyleFingerprint(UuidPk, CreatedAt, Base):
__tablename__ = "style_fingerprint"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
dimensions_json: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
evidence_json: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1"))
class Rule(UuidPk, CreatedAt, Base):
__tablename__ = "rules"
project_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE")
)
level: Mapped[str] = mapped_column(Text, nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
class Chapter(UuidPk, CreatedAt, Base):
__tablename__ = "chapters"
__table_args__ = (UniqueConstraint("project_id", "chapter_no", "version"),)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
volume: Mapped[int] = mapped_column(Integer, nullable=False)
chapter_no: Mapped[int] = mapped_column(Integer, nullable=False)
content: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'draft'"))
version: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1"))
class ChapterReview(UuidPk, CreatedAt, Base):
__tablename__ = "chapter_reviews"
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
chapter_no: Mapped[int] = mapped_column(Integer, nullable=False)
chapter_version: Mapped[int | None] = mapped_column(Integer)
conflicts: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
foreshadow_sug: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
style: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
pace: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
health_score: Mapped[int | None] = mapped_column(Integer)
decisions: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
# ---- 运营/系统表(续)----
class ProviderCredential(UuidPk, CreatedAt, Base):
__tablename__ = "provider_credentials"
__table_args__ = (UniqueConstraint("owner_id", "project_id", "provider"),)
owner_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
project_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE")
)
provider: Mapped[str] = mapped_column(Text, nullable=False)
api_key_enc: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
class TierRouting(UuidPk, Base):
__tablename__ = "tier_routing"
__table_args__ = (UniqueConstraint("project_id", "tier"),)
project_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE")
)
tier: Mapped[str] = mapped_column(Text, nullable=False)
provider: Mapped[str] = mapped_column(Text, nullable=False)
model: Mapped[str] = mapped_column(Text, nullable=False)
fallback: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
class UsageLedger(UuidPk, CreatedAt, Base):
__tablename__ = "usage_ledger"
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
project_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("projects.id"))
provider: Mapped[str] = mapped_column(Text, nullable=False)
model: Mapped[str] = mapped_column(Text, nullable=False)
input_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
output_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
cache_read: Mapped[int] = mapped_column(Integer, server_default=text("0"))
cost_minor: Mapped[int] = mapped_column(BigInteger, nullable=False)
currency: Mapped[str] = mapped_column(Text, nullable=False)
class Skill(UuidPk, CreatedAt, Base):
__tablename__ = "skills"
scope: Mapped[str] = mapped_column(Text, nullable=False)
owner_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("users.id"))
name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
tier: Mapped[str] = mapped_column(Text, nullable=False)
system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
input_schema: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
output_schema: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
reads: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
writes: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
genre: Mapped[str | None] = mapped_column(Text)
examples: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
class Job(UuidPk, TimestampedMixin, Base):
__tablename__ = "jobs"
project_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE")
)
kind: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'queued'"))
progress: Mapped[int] = mapped_column(Integer, server_default=text("0"))
result: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
error: Mapped[str | None] = mapped_column(Text)

View File

@@ -0,0 +1,27 @@
"""async engine / sessionARCH §2.3同进程async-first"""
from __future__ import annotations
from collections.abc import AsyncIterator
from functools import lru_cache
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from ww_config import get_settings
@lru_cache
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
settings = get_settings()
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
return async_sessionmaker(engine, expire_on_commit=False)
async def get_session() -> AsyncIterator[AsyncSession]:
"""FastAPI 依赖:每请求一个 session。"""
sm = get_sessionmaker()
async with sm() as session:
yield session

View File

@@ -0,0 +1,12 @@
[project]
name = "ww-shared"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = ["pydantic>=2.7"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["ww_shared"]

View File

@@ -0,0 +1,4 @@
from ww_shared.envelope import ErrorBody, ErrorEnvelope
from ww_shared.errors import AppError, ErrorCode
__all__ = ["AppError", "ErrorCode", "ErrorBody", "ErrorEnvelope"]

View File

@@ -0,0 +1,16 @@
"""统一错误信封ARCH §7.1)——带 request_id 以便端到端 grep。"""
from __future__ import annotations
from pydantic import BaseModel, Field
class ErrorBody(BaseModel):
code: str
message: str
details: dict[str, object] = Field(default_factory=dict)
request_id: str | None = None
class ErrorEnvelope(BaseModel):
error: ErrorBody

View File

@@ -0,0 +1,44 @@
"""统一错误码与领域异常ARCH §7.1)。"""
from __future__ import annotations
from enum import StrEnum
class ErrorCode(StrEnum):
NOT_FOUND = "NOT_FOUND"
VALIDATION = "VALIDATION"
CONFLICT_UNRESOLVED = "CONFLICT_UNRESOLVED" # 未决冲突禁验收
LLM_UNAVAILABLE = "LLM_UNAVAILABLE" # 回退耗尽
RATE_LIMITED = "RATE_LIMITED"
INTERNAL = "INTERNAL"
# 错误码 -> HTTP 状态
HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.NOT_FOUND: 404,
ErrorCode.VALIDATION: 422,
ErrorCode.CONFLICT_UNRESOLVED: 409,
ErrorCode.LLM_UNAVAILABLE: 503,
ErrorCode.RATE_LIMITED: 429,
ErrorCode.INTERNAL: 500,
}
class AppError(Exception):
"""领域异常:在边界处映射为统一错误信封。"""
def __init__(
self,
code: ErrorCode,
message: str,
details: dict[str, object] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
@property
def http_status(self) -> int:
return HTTP_STATUS.get(self.code, 500)

View File

@@ -0,0 +1,2 @@
# core (@llm/@backend) — 占位骨架
领域核心(domain/状态机)、编排器(orchestrator/LangGraph 图)、记忆服务(memory)。Phase 1+ 由对应 owner 填充。

View File

51
pyproject.toml Normal file
View File

@@ -0,0 +1,51 @@
[tool.uv.workspace]
members = [
"apps/api",
"packages/shared",
"packages/config",
"packages/db",
"packages/llm_gateway",
"packages/core",
"packages/agents",
]
[dependency-groups]
dev = [
"pytest>=8",
"pytest-asyncio>=0.24",
"ruff>=0.6",
"mypy>=1.11",
"httpx>=0.27",
"asgi-lifespan>=2.1",
]
[tool.ruff]
line-length = 100
target-version = "py312"
src = ["apps", "packages"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.mypy]
python_version = "3.12"
mypy_path = [
"packages/shared",
"packages/config",
"packages/db",
"packages/llm_gateway",
"packages/core",
"packages/agents",
"apps/api",
]
namespace_packages = true
strict = true
ignore_missing_imports = true
explicit_package_bases = true
# 跨包聚合 mypy 时,多个无包 tests 目录的 conftest.py 会撞成同名模块 `conftest`。
# conftest 仅含测试 fixtures从聚合检查中排除test_*.py / fakes_* 仍按唯一名受检)。
exclude = ["(^|/)conftest\\.py$"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests", "apps", "packages"]

0
tests/__init__.py Normal file
View File

14
tests/conftest.py Normal file
View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from collections.abc import AsyncIterator
import httpx
import pytest
from ww_api.main import app
@pytest.fixture
async def client() -> AsyncIterator[httpx.AsyncClient]:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from ww_shared import AppError, ErrorBody, ErrorCode, ErrorEnvelope
def test_app_error_maps_to_http_status() -> None:
assert AppError(ErrorCode.NOT_FOUND, "x").http_status == 404
assert AppError(ErrorCode.CONFLICT_UNRESOLVED, "x").http_status == 409
def test_envelope_serializes_with_request_id() -> None:
env = ErrorEnvelope(
error=ErrorBody(code=ErrorCode.VALIDATION, message="bad", request_id="rid-1")
)
dumped = env.model_dump()
assert dumped["error"]["code"] == "VALIDATION"
assert dumped["error"]["request_id"] == "rid-1"

24
tests/test_health.py Normal file
View File

@@ -0,0 +1,24 @@
from __future__ import annotations
import httpx
async def test_root_returns_ok(client: httpx.AsyncClient) -> None:
resp = await client.get("/")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
async def test_health_endpoint(client: httpx.AsyncClient) -> None:
resp = await client.get("/health")
assert resp.status_code == 200
async def test_response_carries_request_id_header(client: httpx.AsyncClient) -> None:
resp = await client.get("/health")
assert resp.headers.get("x-request-id")
async def test_propagates_incoming_request_id(client: httpx.AsyncClient) -> None:
resp = await client.get("/health", headers={"x-request-id": "abc123"})
assert resp.headers["x-request-id"] == "abc123"

View File

@@ -0,0 +1,55 @@
"""集成测试GET /jobs/:id 走真实 DB无 DB 时跳过)。
get_sessionmaker 缓存的 async engine 绑定首个事件循环pytest-asyncio 每个
测试用新循环,故每个 DB 测试清缓存以在当前循环上重建 engine并在结束 dispose。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
import httpx
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ww_db import get_sessionmaker
from ww_db.models import Job
@pytest.fixture
async def sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
get_sessionmaker.cache_clear()
maker = get_sessionmaker()
try:
async with maker() as probe:
await probe.execute(text("select 1"))
except Exception:
pytest.skip("postgres not reachable")
yield maker
await maker.kw["bind"].dispose()
get_sessionmaker.cache_clear()
async def test_get_job_returns_row(
client: httpx.AsyncClient, sm: async_sessionmaker[AsyncSession]
) -> None:
async with sm() as s:
job = Job(kind="style_learn", status="running", progress=42)
s.add(job)
await s.commit()
job_id = job.id
resp = await client.get(f"/jobs/{job_id}")
assert resp.status_code == 200
body = resp.json()
assert body["kind"] == "style_learn"
assert body["progress"] == 42
async def test_get_missing_job_returns_envelope(
client: httpx.AsyncClient, sm: async_sessionmaker[AsyncSession]
) -> None:
resp = await client.get("/jobs/00000000-0000-0000-0000-000000000000")
assert resp.status_code == 404
err = resp.json()["error"]
assert err["code"] == "NOT_FOUND"
assert err["request_id"]

10
tests/test_openapi.py Normal file
View File

@@ -0,0 +1,10 @@
from __future__ import annotations
from ww_api.main import app
def test_openapi_exposes_phase0_endpoints() -> None:
paths = app.openapi()["paths"]
assert "/" in paths
assert "/health" in paths
assert "/jobs/{job_id}" in paths

2718
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff