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:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Postgres (docker-compose 默认)
|
||||
DATABASE_URL=postgresql+asyncpg://writer:writer@localhost:5432/writer
|
||||
# 同步 URL(Alembic 迁移用)
|
||||
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
64
.github/workflows/ci.yml
vendored
Normal 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
20
.gitignore
vendored
Normal 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
960
ARCHITECTURE.md
Normal 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.4;Agent 间零直连,降耦合、可独立替换。 |
|
||||
| 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.js(TS)+ 后端 FastAPI(Python)分离** | 前端 SSR/流式友好;后端 Python 是 LLM 编排/工具的强生态;契约经 OpenAPI→TS 代码生成。 |
|
||||
| ADR-9 | 长任务 | **FastAPI BackgroundTasks + `jobs` 表**(原型不上专门队列) | 学文风/全书扫描走后台任务 + 状态表;写章/审稿走 SSE 即时反馈。规模化再上 arq/Celery。 |
|
||||
| ADR-10 | 编排实现 | **LangGraph(Python)** | 写章→四审→验收天然是跨请求的暂停-恢复(HITL);LangGraph 的并行/中断/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 中断 + checkpoint(ADR-10) |
|
||||
| 数据库 | **PostgreSQL**(无向量) | 关系一库;事务保证验收写回原子性;向量检索为 P2 再引入 |
|
||||
| ORM/迁移 | **SQLAlchemy 2.0(async)+ 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**:单主库起步;读多可加只读副本(看板/列表查询走副本)。
|
||||
- **横向扩展**:原型单实例即可;后端无状态,规模化可多实例 + 抽独立 Worker(arq/Celery)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据架构 ← PRODUCT_SPEC §3.2
|
||||
|
||||
产品意图:记忆即真相源,每章写前检索注入、验收后增量更新;不可变追加保留历史。
|
||||
|
||||
### 3.1 完整 DDL(11 张创作表 + 运营表)
|
||||
|
||||
> 类型以 PostgreSQL 表述;`jsonb` 用于结构化但 schema 演进频繁的字段。所有业务表带 `project_id` 外键(原型单用户,多租户行级隔离为后续)。**原型无向量列**——记忆选择走确定性逻辑(§3.4);向量检索为 P2,届时再加 `embedding vector(N)` 列与 HNSW 索引。
|
||||
> 下面先列 **11 张创作数据表**(对齐 PRODUCT_SPEC §3.2;其中 `timeline`/`decisions` 为 P2,MVP 可不建),再列 **运营/系统表**(用户/凭据/用量/技能/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 可配,默认 3–5)。
|
||||
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),节点固定、边确定;用其**并行**跑四审、用 **interrupt(HITL)+ 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 读 AgentSpec(builtin/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 文风模仿
|
||||
|
||||
- **提取(一次性,分析档)**:3–5 样本(≥5 万字)→ 16 维指纹(9 通用 + 7 中文),**每维附原文证据**,存 `style_fingerprint`(版本化、可增量合并)。
|
||||
- **生成约束**:写章注入指纹(缓存稳定块)。
|
||||
- **漂移打分(高频,轻量档)**:每段对照指纹打相似度分;低于阈值标漂移段。
|
||||
- **回炉**:仅重写漂移段、保留上下文,新旧 diff(UX §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(缓存 + 失效) |
|
||||
| 编辑器本地态(正文/光标/未保存) | 局部 store(Zustand 或 RSC + local) |
|
||||
| 流式增量(draft/review) | SSE 订阅 → 增量 reducer |
|
||||
| 乐观更新(验收/裁决) | 先改 UI、失败回滚 + Toast |
|
||||
|
||||
### 8.3 编辑器与流式渲染
|
||||
|
||||
- 正文编辑器:宋体 / 720px / 行高 1.9(UX §2.3);contentEditable 或 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.5);SSE 断流可续;正文自动保存兜底;长任务可重试幂等。
|
||||
- **配置**:`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 引入向量时再开);定时备份 + PITR;Alembic 迁移随发布执行、CI 校验。
|
||||
- **扩展性/配额**:写章并发受 LLM 限流约束——按作品**速率配额**;网关侧聚合各 provider 限流(429 退避 + 回退)。规模化再抽独立 Worker(arq/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
|
||||
|
||||
按依赖关系排架构落地顺序(与产品 M1–M5 对齐):
|
||||
|
||||
| 里程碑 | 架构交付 | 依赖 |
|
||||
|---|---|---|
|
||||
| **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 运行时 + 规则/命令面板 | M2–M4 |
|
||||
|
||||
> 网关的多 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 路线(按 P0–P2 落地) |
|
||||
| §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` 列/HNSW;PRODUCT_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
146
CLAUDE.md
Normal 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 0–5, 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 workspace,4 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/pytest;frontend 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
158
DEV_PLAN.md
Normal 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(纯 UI,OpenAPI 生成的 TS 客户端调后端) |
|
||||
| 后端 | Python + FastAPI(async, 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_SPEC(Python/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 + usage;mock provider 可注入;一次调用产生一条 `usage_ledger` 记录。· 锚点 ARCH §4.1–4.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.5–T1.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] API:outline + 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] API:style + 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.4–4.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.1):Repository 层加 `owner_id` 校验,接 Auth.js/OAuth。
|
||||
- 向量检索(P2):`select_relevant_entities` 接口后接 pgvector + 嵌入服务。
|
||||
- 专门队列:BackgroundTasks → arq/Celery(接口不变)。
|
||||
- 全书一致性回归扫描、社区 Skill 市场、夜读模式。
|
||||
569
PRODUCT_SPEC.md
Normal file
569
PRODUCT_SPEC.md
Normal 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) |
|
||||
|
||||
**MVP(P0)闭环**:立项 → 设定库 → 大纲 → 写章 → 一致性校验 → 验收(更新摘要+记忆),底层带多模型路由 + 缓存 + 检索。
|
||||
**第二期(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:
|
||||
|
||||
| 类型 | 例子 | 实现 |
|
||||
|---|---|---|
|
||||
| **Agent(LLM 认知任务)** | 世界观设计、角色生成、写章、连续性校对、文风/节奏审查 | 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 骨架**:建**全部创作表**(含 rules,writer/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 式创作工具
|
||||
- DOME(arXiv 2412.13575)— 动态分层大纲 + 知识图谱记忆
|
||||
- RecurrentGPT(arXiv 2305.13304)— 自然语言记忆滚动生成
|
||||
- Lost in Stories(arXiv 2603.05890)— 长篇一致性 Bug 分类
|
||||
502
UX_SPEC.md
Normal file
502
UX_SPEC.md
Normal file
@@ -0,0 +1,502 @@
|
||||
# 网文创作工作流 · UX/UI 规格说明
|
||||
|
||||
> 配套 [PRODUCT_SPEC.md](./PRODUCT_SPEC.md) 的用户体验与界面规格。视觉方向:**文学温暖 · 纸感**(暖米白、衬线、书卷气)。
|
||||
> 形态:方案设计阶段产物,含信息架构、用户流程、页面线框、组件库、交互规范。
|
||||
|
||||
---
|
||||
|
||||
## 0. 文档状态
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 阶段 | UX/UI 设计(未实现) |
|
||||
| 目标用户 | 中文网文作者,长时间连续写作,需在「专注正文」与「纵览设定/伏笔」间切换 |
|
||||
| 视觉方向 | 文学温暖·纸感:暖米白背景 + 宋体正文 + 朱砂印章红强调 |
|
||||
| 平台 | 响应式 Web(桌面优先,平板适配;不做移动原生) |
|
||||
| 对齐 | 功能/端点/数据对齐 PRODUCT_SPEC.md §2.5 / §6 / §3.2(P2 项如时间线/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 动效
|
||||
|
||||
- 时长 150–250ms,缓动 `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│ │
|
||||
└────────┴─────────────────────────────────────────────────────┘
|
||||
左栏 72–200px(可折叠为图标) 主区暖米白底
|
||||
```
|
||||
|
||||
- 左栏图标 + 文字,当前项朱砂竖条 + 浅晕底。
|
||||
- 顶栏:作品名(衬线)、连载进度、全局搜索、设置、头像。
|
||||
|
||||
---
|
||||
|
||||
## 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.9;AI 段落淡入;冲突句下朱砂波浪线,行内挂 `⚠` 锚点。
|
||||
- **底部操作条**:`写本章 / 审稿 / 验收` 三主按钮 + 字数 + 自动保存时间。
|
||||
- **右栏「本章助手」**:
|
||||
- *本章注入*:透明展示喂给 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 都有**就地跳转** + **裁决按钮**(采纳/忽略/回炉/登记);顶部健康分(0–100)。冲突=赭红、逾期=琥珀、通过=黛绿。
|
||||
|
||||
### 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 三栏全开;1024–1280 右栏转抽屉;<1024(平板)单栏 + 浮动工具,侧栏抽屉化。移动端只读+轻编辑。
|
||||
- **可访问性**:正文/背景对比 ≥ 7:1(墨 on 米白达标);朱砂红配文字另加图标/文案,不单靠色;全键盘可达;焦点环朱砂色;尊重 `prefers-reduced-motion`(关打字机动效)。
|
||||
- **长写护眼**:行宽限 720px、行高 1.9;后续可加「夜读模式」与字号/行距偏好。
|
||||
|
||||
---
|
||||
|
||||
## 11. 落地优先级(对齐 PRODUCT_SPEC §9 M1–M5)
|
||||
|
||||
- **M1(P0)**: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
37
alembic.ini
Normal 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
9
apps/api/Dockerfile
Normal 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
29
apps/api/pyproject.toml
Normal 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 }
|
||||
44
apps/api/tests/conftest.py
Normal file
44
apps/api/tests/conftest.py
Normal 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)
|
||||
0
apps/api/ww_api/__init__.py
Normal file
0
apps/api/ww_api/__init__.py
Normal file
15
apps/api/ww_api/export_openapi.py
Normal file
15
apps/api/ww_api/export_openapi.py
Normal 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()
|
||||
34
apps/api/ww_api/logging_config.py
Normal file
34
apps/api/ww_api/logging_config.py
Normal 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
55
apps/api/ww_api/main.py
Normal 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()
|
||||
28
apps/api/ww_api/middleware.py
Normal file
28
apps/api/ww_api/middleware.py
Normal 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
|
||||
0
apps/api/ww_api/routers/__init__.py
Normal file
0
apps/api/ww_api/routers/__init__.py
Normal file
17
apps/api/ww_api/routers/health.py
Normal file
17
apps/api/ww_api/routers/health.py
Normal 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"}
|
||||
33
apps/api/ww_api/routers/jobs.py
Normal file
33
apps/api/ww_api/routers/jobs.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""长任务轮询端点 GET /jobs/:id(ARCH §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,
|
||||
}
|
||||
1
apps/api/ww_api/schemas/__init__.py
Normal file
1
apps/api/ww_api/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API 边界 Pydantic schemas(snake_case;前端经 OpenAPI 消费)。"""
|
||||
1
apps/api/ww_api/security/__init__.py
Normal file
1
apps/api/ww_api/security/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""安全工具:凭据加解密与掩码(ARCH §4.7)。"""
|
||||
1
apps/api/ww_api/services/__init__.py
Normal file
1
apps/api/ww_api/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API 服务层:凭据存取与提供商探测(依赖接口,便于测试注入替身)。"""
|
||||
1
apps/web/.eslintrc.json
Normal file
1
apps/web/.eslintrc.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "extends": "next/core-web-vitals" }
|
||||
4
apps/web/.gitignore
vendored
Normal file
4
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules
|
||||
/.next
|
||||
/lib/api/openapi.json
|
||||
next-env.d.ts
|
||||
15
apps/web/Dockerfile
Normal file
15
apps/web/Dockerfile
Normal 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
50
apps/web/app/globals.css
Normal file
@@ -0,0 +1,50 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 纸感设计 token(UX_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-motion(UX §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
23
apps/web/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/web/components/AppShell.tsx
Normal file
46
apps/web/components/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
apps/web/components/LeftNav.tsx
Normal file
66
apps/web/components/LeftNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
apps/web/components/Toast.tsx
Normal file
73
apps/web/components/Toast.tsx
Normal 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;
|
||||
}
|
||||
8
apps/web/lib/api/client.test.ts
Normal file
8
apps/web/lib/api/client.test.ts
Normal 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?:\/\//);
|
||||
});
|
||||
});
|
||||
8
apps/web/lib/api/client.ts
Normal file
8
apps/web/lib/api/client.ts
Normal 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 });
|
||||
8
apps/web/lib/api/config.ts
Normal file
8
apps/web/lib/api/config.ts
Normal 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
987
apps/web/lib/api/schema.d.ts
vendored
Normal 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.5):gate(事务前)→ 终稿提炼 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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
37
apps/web/lib/api/server.ts
Normal file
37
apps/web/lib/api/server.ts
Normal 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
27
apps/web/lib/api/types.ts
Normal 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
5
apps/web/next.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
export default nextConfig;
|
||||
33
apps/web/package.json
Normal file
33
apps/web/package.json
Normal 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
4906
apps/web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
apps/web/pnpm-workspace.yaml
Normal file
11
apps/web/pnpm-workspace.yaml
Normal 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
|
||||
3
apps/web/postcss.config.mjs
Normal file
3
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
};
|
||||
26
apps/web/scripts/gen-api.mjs
Normal file
26
apps/web/scripts/gen-api.mjs
Normal 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) 后端 OpenAPI(uv 运行,离线,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);
|
||||
32
apps/web/tailwind.config.ts
Normal file
32
apps/web/tailwind.config.ts
Normal 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
21
apps/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
14
apps/web/vitest.config.ts
Normal file
14
apps/web/vitest.config.ts
Normal 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
43
docker-compose.yml
Normal 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:
|
||||
15
packages/config/pyproject.toml
Normal file
15
packages/config/pyproject.toml
Normal 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 }
|
||||
3
packages/config/ww_config/__init__.py
Normal file
3
packages/config/ww_config/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ww_config.settings import Settings, get_settings
|
||||
|
||||
__all__ = ["Settings", "get_settings"]
|
||||
29
packages/config/ww_config/settings.py
Normal file
29
packages/config/ww_config/settings.py
Normal 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()
|
||||
54
packages/db/migrations/env.py
Normal file
54
packages/db/migrations/env.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Alembic async env(CLAUDE.md:checkpointer.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 URL(asyncpg)运行迁移
|
||||
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())
|
||||
27
packages/db/migrations/script.py.mako
Normal file
27
packages/db/migrations/script.py.mako
Normal 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"}
|
||||
0
packages/db/migrations/versions/.gitkeep
Normal file
0
packages/db/migrations/versions/.gitkeep
Normal 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 ###
|
||||
23
packages/db/pyproject.toml
Normal file
23
packages/db/pyproject.toml
Normal 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 }
|
||||
4
packages/db/ww_db/__init__.py
Normal file
4
packages/db/ww_db/__init__.py
Normal 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
29
packages/db/ww_db/base.py
Normal 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
236
packages/db/ww_db/models.py
Normal 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)
|
||||
27
packages/db/ww_db/session.py
Normal file
27
packages/db/ww_db/session.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""async engine / session(ARCH §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
|
||||
12
packages/shared/pyproject.toml
Normal file
12
packages/shared/pyproject.toml
Normal 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"]
|
||||
4
packages/shared/ww_shared/__init__.py
Normal file
4
packages/shared/ww_shared/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from ww_shared.envelope import ErrorBody, ErrorEnvelope
|
||||
from ww_shared.errors import AppError, ErrorCode
|
||||
|
||||
__all__ = ["AppError", "ErrorCode", "ErrorBody", "ErrorEnvelope"]
|
||||
16
packages/shared/ww_shared/envelope.py
Normal file
16
packages/shared/ww_shared/envelope.py
Normal 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
|
||||
44
packages/shared/ww_shared/errors.py
Normal file
44
packages/shared/ww_shared/errors.py
Normal 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)
|
||||
2
packages/skills/README.md
Normal file
2
packages/skills/README.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# core (@llm/@backend) — 占位骨架
|
||||
领域核心(domain/状态机)、编排器(orchestrator/LangGraph 图)、记忆服务(memory)。Phase 1+ 由对应 owner 填充。
|
||||
0
packages/skills/ww_skills/__init__.py
Normal file
0
packages/skills/ww_skills/__init__.py
Normal file
51
pyproject.toml
Normal file
51
pyproject.toml
Normal 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
0
tests/__init__.py
Normal file
14
tests/conftest.py
Normal file
14
tests/conftest.py
Normal 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
|
||||
17
tests/test_error_envelope.py
Normal file
17
tests/test_error_envelope.py
Normal 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
24
tests/test_health.py
Normal 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"
|
||||
55
tests/test_jobs_integration.py
Normal file
55
tests/test_jobs_integration.py
Normal 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
10
tests/test_openapi.py
Normal 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
|
||||
Reference in New Issue
Block a user