diff --git a/apps/api/tests/fakes_projects.py b/apps/api/tests/fakes_projects.py
index c9dc403..cfe6c49 100644
--- a/apps/api/tests/fakes_projects.py
+++ b/apps/api/tests/fakes_projects.py
@@ -55,6 +55,9 @@ class FakeProjectRepo:
theme=data.theme,
selling_points=list(data.selling_points),
structure=data.structure,
+ tone=data.tone,
+ ending_type=data.ending_type,
+ narrative_pov=data.narrative_pov,
updated_at=self.now,
)
self.rows[pid] = (owner_id, view)
diff --git a/apps/api/tests/test_projects.py b/apps/api/tests/test_projects.py
index 1504933..5cda220 100644
--- a/apps/api/tests/test_projects.py
+++ b/apps/api/tests/test_projects.py
@@ -150,6 +150,54 @@ async def test_create_project_returns_201() -> None:
assert uuid.UUID(body["id"])
+@pytest.mark.asyncio
+async def test_create_project_persists_tone_ending_pov() -> None:
+ # ④ 立项新增基调/结局/视角:请求 → repo 落库 → 响应回显(snake_case 契约)。
+ client, repo, _, _ = _make_client()
+ async with client:
+ resp = await client.post(
+ "/projects",
+ json={
+ "title": "基调作品",
+ "tone": "热血",
+ "ending_type": "HE",
+ "narrative_pov": "第一人称",
+ },
+ )
+ assert resp.status_code == 201
+ body = resp.json()
+ assert body["tone"] == "热血"
+ assert body["ending_type"] == "HE"
+ assert body["narrative_pov"] == "第一人称"
+ (_owner, view) = next(iter(repo.rows.values()))
+ assert view.tone == "热血"
+ assert view.ending_type == "HE"
+ assert view.narrative_pov == "第一人称"
+
+
+@pytest.mark.asyncio
+async def test_get_project_exposes_tone_ending_pov() -> None:
+ client, _, _, _ = _make_client()
+ async with client:
+ created = (
+ await client.post(
+ "/projects",
+ json={
+ "title": "书",
+ "tone": "沉重",
+ "ending_type": "BE",
+ "narrative_pov": "多视角",
+ },
+ )
+ ).json()
+ resp = await client.get(f"/projects/{created['id']}")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["tone"] == "沉重"
+ assert body["ending_type"] == "BE"
+ assert body["narrative_pov"] == "多视角"
+
+
@pytest.mark.asyncio
async def test_list_projects() -> None:
client, repo, _, _ = _make_client()
diff --git a/apps/api/ww_api/routers/projects.py b/apps/api/ww_api/routers/projects.py
index a1603fc..bca5581 100644
--- a/apps/api/ww_api/routers/projects.py
+++ b/apps/api/ww_api/routers/projects.py
@@ -127,6 +127,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
theme=body.theme,
selling_points=body.selling_points,
structure=body.structure,
+ tone=body.tone,
+ ending_type=body.ending_type,
+ narrative_pov=body.narrative_pov,
),
)
log.info("project_created", project_id=str(view.id), title_len=len(view.title))
diff --git a/apps/api/ww_api/schemas/projects.py b/apps/api/ww_api/schemas/projects.py
index ec7b6cf..0b504b6 100644
--- a/apps/api/ww_api/schemas/projects.py
+++ b/apps/api/ww_api/schemas/projects.py
@@ -22,6 +22,10 @@ class ProjectCreateRequest(BaseModel):
theme: str | None = None
selling_points: list[str] = Field(default_factory=list)
structure: str | None = None
+ # 立项书级常量(灵感④)——free text + 前端预设,无 Literal(Literal 只留 Verdict)。
+ tone: str | None = None
+ ending_type: str | None = None
+ narrative_pov: str | None = None
class ProjectResponse(BaseModel):
@@ -35,6 +39,9 @@ class ProjectResponse(BaseModel):
theme: str | None = None
selling_points: list[str] = Field(default_factory=list)
structure: str | None = None
+ tone: str | None = None
+ ending_type: str | None = None
+ narrative_pov: str | None = None
updated_at: datetime | None = Field(default=None, description="项目最近更新时间")
pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数")
diff --git a/apps/web/components/ProjectWizard.tsx b/apps/web/components/ProjectWizard.tsx
index 53d4be4..5353baf 100644
--- a/apps/web/components/ProjectWizard.tsx
+++ b/apps/web/components/ProjectWizard.tsx
@@ -15,10 +15,13 @@ import { TextInput } from "@/components/ui/TextInput";
import { api } from "@/lib/api/client";
import { buttonClass } from "@/lib/ui/variants";
import {
+ ENDING_TYPES,
GENRES,
+ NARRATIVE_POVS,
SELLING_POINT_PRESETS,
STEP_TITLES,
STRUCTURES,
+ TONES,
WIZARD_STEPS,
canAdvance,
canSubmit,
@@ -267,6 +270,37 @@ function StepPremise({ form, update }: StepProps) {
placeholder="例:抗争与代价"
/>
+
+
+
+
+ update({ narrativePov })}
+ options={NARRATIVE_POVS.map((p) => ({ value: p, label: p }))}
+ />
+
+
+ update({ endingType })}
+ options={ENDING_TYPES.map((e) => ({ value: e, label: e }))}
+ />
+
);
}
@@ -302,6 +336,9 @@ function StepConfirm({ form }: { form: WizardForm }) {
label: "核心卖点",
value: form.sellingPoints.length > 0 ? form.sellingPoints.join("、") : "未选择",
},
+ { label: "基调", value: form.tone || "未选择" },
+ { label: "叙事视角", value: form.narrativePov || "未选择" },
+ { label: "结局取向", value: form.endingType || "未选择" },
];
return (
diff --git a/apps/web/lib/api/schema.d.ts b/apps/web/lib/api/schema.d.ts
index 32df181..57eefa4 100644
--- a/apps/web/lib/api/schema.d.ts
+++ b/apps/web/lib/api/schema.d.ts
@@ -1518,6 +1518,12 @@ export interface components {
selling_points?: string[];
/** Structure */
structure?: string | null;
+ /** Tone */
+ tone?: string | null;
+ /** Ending Type */
+ ending_type?: string | null;
+ /** Narrative Pov */
+ narrative_pov?: string | null;
};
/**
* ProjectListResponse
@@ -1551,6 +1557,12 @@ export interface components {
selling_points?: string[];
/** Structure */
structure?: string | null;
+ /** Tone */
+ tone?: string | null;
+ /** Ending Type */
+ ending_type?: string | null;
+ /** Narrative Pov */
+ narrative_pov?: string | null;
/**
* Updated At
* @description 项目最近更新时间
diff --git a/apps/web/lib/wizard/wizard.test.ts b/apps/web/lib/wizard/wizard.test.ts
index 9bd06b3..2c8d7be 100644
--- a/apps/web/lib/wizard/wizard.test.ts
+++ b/apps/web/lib/wizard/wizard.test.ts
@@ -57,6 +57,9 @@ describe("toCreateRequest", () => {
premise: " ",
protagonist: "",
theme: "抗争",
+ tone: "",
+ endingType: "",
+ narrativePov: "",
};
expect(toCreateRequest(form)).toEqual({
title: "逐光而行",
@@ -66,9 +69,34 @@ describe("toCreateRequest", () => {
theme: "抗争",
selling_points: ["逆袭", "系统流"],
structure: "三幕",
+ tone: null,
+ ending_type: null,
+ narrative_pov: null,
});
});
+ // ④ 立项新增基调/结局/视角:camelCase 表单 → snake_case 请求,空值落 null。
+ it("maps tone, endingType, narrativePov to snake_case fields", () => {
+ const form: WizardForm = {
+ ...emptyWizardForm,
+ title: "书",
+ tone: "热血",
+ endingType: "HE",
+ narrativePov: "第一人称",
+ };
+ const req = toCreateRequest(form);
+ expect(req.tone).toBe("热血");
+ expect(req.ending_type).toBe("HE");
+ expect(req.narrative_pov).toBe("第一人称");
+ });
+
+ it("nulls empty tone, endingType, narrativePov", () => {
+ const req = toCreateRequest({ ...emptyWizardForm, title: "书" });
+ expect(req.tone).toBeNull();
+ expect(req.ending_type).toBeNull();
+ expect(req.narrative_pov).toBeNull();
+ });
+
// QA H1 回归:立意(premise)与主角/金手指(protagonist)是两个独立输入,
// 不能互相覆盖——提交时各占一段合并进 premise。
it("merges premise and protagonist into premise without overwriting", () => {
diff --git a/apps/web/lib/wizard/wizard.ts b/apps/web/lib/wizard/wizard.ts
index 1dcd275..3a1abef 100644
--- a/apps/web/lib/wizard/wizard.ts
+++ b/apps/web/lib/wizard/wizard.ts
@@ -10,6 +10,10 @@ export interface WizardForm {
premise: string;
protagonist: string;
theme: string;
+ // 立项书级常量(灵感④):基调 / 结局取向 / 叙事视角。
+ tone: string;
+ endingType: string;
+ narrativePov: string;
}
export const WIZARD_STEPS = 5;
@@ -32,11 +36,18 @@ export const emptyWizardForm: WizardForm = {
premise: "",
protagonist: "",
theme: "",
+ tone: "",
+ endingType: "",
+ narrativePov: "",
};
export const GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"];
export const STRUCTURES = ["三幕", "故事圈", "雪花"];
export const SELLING_POINT_PRESETS = ["逆袭", "打脸", "系统流", "双男主", "群像"];
+// 立项书级常量预设(灵感④ / D 枚举)——free text 单选,命不中预设可留空。
+export const TONES = ["热血", "轻松", "沉重", "治愈", "暗黑", "爽文"];
+export const ENDING_TYPES = ["HE", "BE", "开放", "正剧"];
+export const NARRATIVE_POVS = ["第一人称", "第三人称限知", "第三人称全知", "多视角"];
// 每一步可否前进:仅第 1 步(书名)必填,其余可空着继续(草稿式立项)。
export function canAdvance(step: number, form: WizardForm): boolean {
@@ -75,5 +86,8 @@ export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
theme: trim(form.theme),
selling_points: form.sellingPoints,
structure: trim(form.structure),
+ tone: trim(form.tone),
+ ending_type: trim(form.endingType),
+ narrative_pov: trim(form.narrativePov),
};
}
diff --git a/memory/contracts.md b/memory/contracts.md
index 97b6b31..3f091e2 100644
--- a/memory/contracts.md
+++ b/memory/contracts.md
@@ -346,6 +346,8 @@
## 契约变更日志(append-only)
> 格式:`- [date] @skill 改 Cx:<改了什么> → 影响 <依赖方/任务>`
+- [2026-07-06] @db/@backend/@frontend 改 C2+C3(④ 立项基调/结局/视角):`projects` 表加 3 列 `tone`/`ending_type`/`narrative_pov`(**Text nullable、无 CHECK**,同 genre/structure;迁移 `e5f6a7b8c9d0`,nullable 免 backfill)。`ProjectCreateRequest`+`ProjectResponse`(`schemas/projects.py`)各加 3 字段 `str|None`(**无 Literal**,Literal 只留 Verdict);domain `ProjectCreate`/`ProjectView`/`_to_view`/`SqlProjectRepo.create` + `FakeProjectRepo` 同步透传。完整 stable_core 链路:`ProjectSpecView`(`repositories.py`,frozen)加 3 字段 + `SqlProjectSpecRepo.spec` 查询构造带上 + `_build_spec_section`(`assemble.py`)渲染进「作品蓝本」块(**书级常量→缓存前缀,字节稳定守 #9**,绝不入 volatile)。前端 `wizard.ts` `WizardForm`/`emptyWizardForm`/`toCreateRequest` + 预设数组 `TONES`/`ENDING_TYPES`/`NARRATIVE_POVS`(仿 GENRES/STRUCTURES)+ `ProjectWizard.tsx` 第 3 步控件(tone→Select,pov/ending→SegmentedControl)+ 确认页回显。**codegen**:3 字段皆 `= None` 无默认工厂 → openapi-typescript 渲染为 `?: string | null` 可选。→ 影响 @frontend(已 `pnpm gen:api`);未来 ⑤ AI 立项方案回填这 3 字段。
+
- [2026-07-06] @llm/@backend 改 C3(⑧ appearance/motive 穿线):`CharacterCardView`(`schemas/generation.py`)+ `ww_agents.CharacterCard` 各加 `appearance:str=""` + `motive:str=""`(**均给默认值守解析韧性**,DB 列 `characters.appearance/motive` 早已在初始迁移、此前一直 NULL——**无新迁移**)。写侧 `CharacterWriteRepo.create`/`SqlCharacterWriteRepo`(create+**update backfill 分支**)/`CharacterWriteFields` 同步加两列;`routers/generation.py` `_card_to_view`/`_view_to_card`/`_existing_characters` 双向形变带上两字段。同批**重切 motive/traits/arc 口径**:动机唯一落 `motive`,`traits[]` 只留核心/表层/阴影三层,`arc` 引用 motive(改 `character-gen.md` → 已 regen `prompt_hashes.json` 金标准)。**codegen 注意**:因带 `default`,openapi-typescript 把两字段渲染为**必填**(响应恒有值),前端 `CharacterCardView` 字面量构造点须显式给两字段。→ 影响 @frontend(已 `pnpm gen:api` + `CharacterCardItem` 展示/编辑两字段);未来 ③ 人物塑造审查以 motive/appearance 为客观锚点。
- [2026-06-24] @llm/@backend 立 C6-ext(Prompt 外置方案A):21 prompt 散文外置 `prompts/.md` + `load_prompt`/`SPECS`/`SCHEMA_CATALOG`/`REVIEW_RESERVED_NAMES`(@llm)+ `SpecResolver` + SkillRegistry 入库守卫前移 + toolbox 桥接 `SPECS`(@backend)+ `.gitattributes`/wheel package-data/CI 冒烟(@devops)。随后全量重写 21 prompt 内容(任务对齐,schema 契约/不变量保持,金标准 fixture 重生成)。门禁绿:ruff/format/mypy(209)/pytest(744)。→ 影响:后续波次可将编排器 + apps/api 路由切 `SpecResolver` 并删 `*_spec` 导出;前端零影响(GeneratorTool 描述符字段不变)。
diff --git a/packages/core/tests/test_memory.py b/packages/core/tests/test_memory.py
index 719dcec..54d072a 100644
--- a/packages/core/tests/test_memory.py
+++ b/packages/core/tests/test_memory.py
@@ -418,6 +418,25 @@ async def test_no_genre_injects_no_fragment() -> None:
assert "本作题材写法" not in ctx.stable_core
+# ---- 灵感④:立项基调/结局/视角是书级常量 → 进 stable_core(缓存前缀,不变量 #9) ----
+
+
+async def test_build_stable_includes_tone_ending_pov() -> None:
+ spec = ProjectSpecView(title="书", tone="热血", ending_type="HE", narrative_pov="第一人称")
+ ctx = await assemble(build_repos(spec=spec), PROJECT, 5)
+ assert "热血" in ctx.stable_core
+ assert "HE" in ctx.stable_core
+ assert "第一人称" in ctx.stable_core
+
+
+async def test_tone_ending_pov_never_enter_volatile() -> None:
+ # 书级常量随书稳定 → 只进缓存前缀,绝不进 volatile(章号易变区)。
+ spec = ProjectSpecView(title="书", tone="热血", ending_type="HE", narrative_pov="第一人称")
+ ctx = await assemble(build_repos(spec=spec), PROJECT, 5)
+ assert "热血" not in ctx.volatile
+ assert "第一人称" not in ctx.volatile
+
+
# ---- 灵感①/F1:开篇黄金三章特判(volatile,仅前 3 章) ----
diff --git a/packages/core/tests/test_project_repo.py b/packages/core/tests/test_project_repo.py
index 65e2d82..d91cd8f 100644
--- a/packages/core/tests/test_project_repo.py
+++ b/packages/core/tests/test_project_repo.py
@@ -1,6 +1,16 @@
from datetime import UTC, datetime
-from ww_core.domain.project_repo import max_time
+from ww_core.domain.project_repo import _to_view, max_time
+from ww_db.models import Project
+
+
+def test_to_view_carries_tone_ending_pov() -> None:
+ # ④ 立项三字段(基调/结局/视角)经 ORM 行 → ProjectView 快照逐字段透传(snake_case)。
+ row = Project(title="书", tone="热血", ending_type="BE", narrative_pov="多视角")
+ view = _to_view(row)
+ assert view.tone == "热血"
+ assert view.ending_type == "BE"
+ assert view.narrative_pov == "多视角"
def test_max_time_handles_missing_values() -> None:
diff --git a/packages/core/ww_core/domain/project_repo.py b/packages/core/ww_core/domain/project_repo.py
index ba012b3..f6474a7 100644
--- a/packages/core/ww_core/domain/project_repo.py
+++ b/packages/core/ww_core/domain/project_repo.py
@@ -29,6 +29,9 @@ class ProjectView:
theme: str | None = None
selling_points: list[Any] = field(default_factory=list)
structure: str | None = None
+ tone: str | None = None
+ ending_type: str | None = None
+ narrative_pov: str | None = None
updated_at: datetime | None = None
pending_review_count: int = 0
@@ -44,6 +47,9 @@ class ProjectCreate:
theme: str | None = None
selling_points: list[Any] = field(default_factory=list)
structure: str | None = None
+ tone: str | None = None
+ ending_type: str | None = None
+ narrative_pov: str | None = None
class ProjectRepo(Protocol):
@@ -71,6 +77,9 @@ def _to_view(
theme=row.theme,
selling_points=list(row.selling_points or []),
structure=row.structure,
+ tone=row.tone,
+ ending_type=row.ending_type,
+ narrative_pov=row.narrative_pov,
updated_at=updated_at or row.updated_at,
pending_review_count=pending_review_count,
)
@@ -92,6 +101,9 @@ class SqlProjectRepo:
theme=data.theme,
selling_points=list(data.selling_points),
structure=data.structure,
+ tone=data.tone,
+ ending_type=data.ending_type,
+ narrative_pov=data.narrative_pov,
)
self._s.add(row)
await self._s.commit()
diff --git a/packages/core/ww_core/domain/repositories.py b/packages/core/ww_core/domain/repositories.py
index 022e1c0..cd88f8a 100644
--- a/packages/core/ww_core/domain/repositories.py
+++ b/packages/core/ww_core/domain/repositories.py
@@ -101,6 +101,10 @@ class ProjectSpecView(BaseModel):
theme: str | None = None
# 题材(每书稳定项)——供 assemble 注入 genre-aware craft 片段(灵感 F1)。
genre: str | None = None
+ # 立项书级常量(灵感④):基调/结局取向/叙事视角——每书稳定 → 入 stable_core 缓存前缀。
+ tone: str | None = None
+ ending_type: str | None = None
+ narrative_pov: str | None = None
# ---- Repository 协议(async;统一按 project_id 过滤)----
diff --git a/packages/core/ww_core/memory/assemble.py b/packages/core/ww_core/memory/assemble.py
index 8d438e7..7d36073 100644
--- a/packages/core/ww_core/memory/assemble.py
+++ b/packages/core/ww_core/memory/assemble.py
@@ -69,6 +69,13 @@ def _build_spec_section(spec: ProjectSpecView | None) -> str | None:
lines.append(f"【前提】{spec.premise}")
if spec.theme:
lines.append(f"【主题】{spec.theme}")
+ # 立项书级常量(灵感④):每书稳定 → 随蓝本进缓存前缀,跨章字节不变(不变量 #9)。
+ if spec.tone:
+ lines.append(f"【基调】{spec.tone}")
+ if spec.narrative_pov:
+ lines.append(f"【叙事视角】{spec.narrative_pov}")
+ if spec.ending_type:
+ lines.append(f"【结局取向】{spec.ending_type}")
return _section("作品蓝本", "\n".join(lines))
diff --git a/packages/core/ww_core/memory/sql_repositories.py b/packages/core/ww_core/memory/sql_repositories.py
index fef37f4..6ce69b9 100644
--- a/packages/core/ww_core/memory/sql_repositories.py
+++ b/packages/core/ww_core/memory/sql_repositories.py
@@ -216,6 +216,9 @@ class SqlProjectSpecRepo:
premise=row.premise,
theme=row.theme,
genre=row.genre,
+ tone=row.tone,
+ ending_type=row.ending_type,
+ narrative_pov=row.narrative_pov,
)
diff --git a/packages/db/migrations/versions/e5f6a7b8c9d0_project_tone_ending_pov.py b/packages/db/migrations/versions/e5f6a7b8c9d0_project_tone_ending_pov.py
new file mode 100644
index 0000000..d6c22da
--- /dev/null
+++ b/packages/db/migrations/versions/e5f6a7b8c9d0_project_tone_ending_pov.py
@@ -0,0 +1,33 @@
+"""Add tone/ending_type/narrative_pov to projects (灵感④ 立项书级常量).
+
+三列均 Text nullable(与既有 genre/structure 一致,无 CHECK;枚举约束留前端预设),
+nullable 免 backfill(旧项目留 NULL,assemble/前端优雅降级)。
+
+Revision ID: e5f6a7b8c9d0
+Revises: 8c1d2e3f4a5b
+Create Date: 2026-07-06 00:00:00.000000
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "e5f6a7b8c9d0"
+down_revision: str | None = "8c1d2e3f4a5b"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.add_column("projects", sa.Column("tone", sa.Text(), nullable=True))
+ op.add_column("projects", sa.Column("ending_type", sa.Text(), nullable=True))
+ op.add_column("projects", sa.Column("narrative_pov", sa.Text(), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column("projects", "narrative_pov")
+ op.drop_column("projects", "ending_type")
+ op.drop_column("projects", "tone")
diff --git a/packages/db/ww_db/models.py b/packages/db/ww_db/models.py
index 00e4e2d..aacfb3a 100644
--- a/packages/db/ww_db/models.py
+++ b/packages/db/ww_db/models.py
@@ -48,6 +48,10 @@ class Project(UuidPk, TimestampedMixin, Base):
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)
+ # 立项书级常量(灵感④)——同 genre/structure:Text nullable、无 CHECK(枚举约束留前端预设)。
+ tone: Mapped[str | None] = mapped_column(Text)
+ ending_type: Mapped[str | None] = mapped_column(Text)
+ narrative_pov: Mapped[str | None] = mapped_column(Text)
class Character(UuidPk, CreatedAt, Base):