feat(web): 立项新增 基调/结局/视角 三字段(书级常量入 stable_core 缓存前缀)
灵感计划 §3④ + §4 D 枚举决策: - projects 表加 tone/ending_type/narrative_pov(Text nullable、无 CHECK,同 genre/structure; 迁移 e5f6a7b8c9d0 nullable 免 backfill)。 - ProjectCreateRequest/ProjectResponse + domain ProjectCreate/ProjectView 全链路透传(snake_case)。 - 完整 stable_core 链路:ProjectSpecView + SqlProjectSpecRepo.spec + _build_spec_section 渲染进 「作品蓝本」块——书级常量随书稳定,字节稳定守缓存前缀不变量 #9,绝不入 volatile。 - 前端 wizard.ts 三字段 + 预设数组 TONES/ENDING_TYPES/NARRATIVE_POVS + ProjectWizard 控件与确认页回显。 - 契约变更已 pnpm gen:api + memory/contracts.md 登记。
This commit is contained in:
@@ -55,6 +55,9 @@ class FakeProjectRepo:
|
|||||||
theme=data.theme,
|
theme=data.theme,
|
||||||
selling_points=list(data.selling_points),
|
selling_points=list(data.selling_points),
|
||||||
structure=data.structure,
|
structure=data.structure,
|
||||||
|
tone=data.tone,
|
||||||
|
ending_type=data.ending_type,
|
||||||
|
narrative_pov=data.narrative_pov,
|
||||||
updated_at=self.now,
|
updated_at=self.now,
|
||||||
)
|
)
|
||||||
self.rows[pid] = (owner_id, view)
|
self.rows[pid] = (owner_id, view)
|
||||||
|
|||||||
@@ -150,6 +150,54 @@ async def test_create_project_returns_201() -> None:
|
|||||||
assert uuid.UUID(body["id"])
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_list_projects() -> None:
|
async def test_list_projects() -> None:
|
||||||
client, repo, _, _ = _make_client()
|
client, repo, _, _ = _make_client()
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> Pr
|
|||||||
theme=body.theme,
|
theme=body.theme,
|
||||||
selling_points=body.selling_points,
|
selling_points=body.selling_points,
|
||||||
structure=body.structure,
|
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))
|
log.info("project_created", project_id=str(view.id), title_len=len(view.title))
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ class ProjectCreateRequest(BaseModel):
|
|||||||
theme: str | None = None
|
theme: str | None = None
|
||||||
selling_points: list[str] = Field(default_factory=list)
|
selling_points: list[str] = Field(default_factory=list)
|
||||||
structure: str | None = None
|
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):
|
class ProjectResponse(BaseModel):
|
||||||
@@ -35,6 +39,9 @@ class ProjectResponse(BaseModel):
|
|||||||
theme: str | None = None
|
theme: str | None = None
|
||||||
selling_points: list[str] = Field(default_factory=list)
|
selling_points: list[str] = Field(default_factory=list)
|
||||||
structure: str | None = None
|
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="项目最近更新时间")
|
updated_at: datetime | None = Field(default=None, description="项目最近更新时间")
|
||||||
pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数")
|
pending_review_count: int = Field(default=0, ge=0, description="待审稿章节数")
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ import { TextInput } from "@/components/ui/TextInput";
|
|||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import { buttonClass } from "@/lib/ui/variants";
|
import { buttonClass } from "@/lib/ui/variants";
|
||||||
import {
|
import {
|
||||||
|
ENDING_TYPES,
|
||||||
GENRES,
|
GENRES,
|
||||||
|
NARRATIVE_POVS,
|
||||||
SELLING_POINT_PRESETS,
|
SELLING_POINT_PRESETS,
|
||||||
STEP_TITLES,
|
STEP_TITLES,
|
||||||
STRUCTURES,
|
STRUCTURES,
|
||||||
|
TONES,
|
||||||
WIZARD_STEPS,
|
WIZARD_STEPS,
|
||||||
canAdvance,
|
canAdvance,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
@@ -267,6 +270,37 @@ function StepPremise({ form, update }: StepProps) {
|
|||||||
placeholder="例:抗争与代价"
|
placeholder="例:抗争与代价"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="基调">
|
||||||
|
<Select
|
||||||
|
value={form.tone}
|
||||||
|
onChange={(e) => update({ tone: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">未选择</option>
|
||||||
|
{TONES.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="叙事视角">
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel="叙事视角"
|
||||||
|
className="flex-wrap"
|
||||||
|
value={form.narrativePov}
|
||||||
|
onChange={(narrativePov) => update({ narrativePov })}
|
||||||
|
options={NARRATIVE_POVS.map((p) => ({ value: p, label: p }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="结局取向">
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel="结局取向"
|
||||||
|
className="flex-wrap"
|
||||||
|
value={form.endingType}
|
||||||
|
onChange={(endingType) => update({ endingType })}
|
||||||
|
options={ENDING_TYPES.map((e) => ({ value: e, label: e }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -302,6 +336,9 @@ function StepConfirm({ form }: { form: WizardForm }) {
|
|||||||
label: "核心卖点",
|
label: "核心卖点",
|
||||||
value: form.sellingPoints.length > 0 ? form.sellingPoints.join("、") : "未选择",
|
value: form.sellingPoints.length > 0 ? form.sellingPoints.join("、") : "未选择",
|
||||||
},
|
},
|
||||||
|
{ label: "基调", value: form.tone || "未选择" },
|
||||||
|
{ label: "叙事视角", value: form.narrativePov || "未选择" },
|
||||||
|
{ label: "结局取向", value: form.endingType || "未选择" },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
12
apps/web/lib/api/schema.d.ts
vendored
12
apps/web/lib/api/schema.d.ts
vendored
@@ -1518,6 +1518,12 @@ export interface components {
|
|||||||
selling_points?: string[];
|
selling_points?: string[];
|
||||||
/** Structure */
|
/** Structure */
|
||||||
structure?: string | null;
|
structure?: string | null;
|
||||||
|
/** Tone */
|
||||||
|
tone?: string | null;
|
||||||
|
/** Ending Type */
|
||||||
|
ending_type?: string | null;
|
||||||
|
/** Narrative Pov */
|
||||||
|
narrative_pov?: string | null;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* ProjectListResponse
|
* ProjectListResponse
|
||||||
@@ -1551,6 +1557,12 @@ export interface components {
|
|||||||
selling_points?: string[];
|
selling_points?: string[];
|
||||||
/** Structure */
|
/** Structure */
|
||||||
structure?: string | null;
|
structure?: string | null;
|
||||||
|
/** Tone */
|
||||||
|
tone?: string | null;
|
||||||
|
/** Ending Type */
|
||||||
|
ending_type?: string | null;
|
||||||
|
/** Narrative Pov */
|
||||||
|
narrative_pov?: string | null;
|
||||||
/**
|
/**
|
||||||
* Updated At
|
* Updated At
|
||||||
* @description 项目最近更新时间
|
* @description 项目最近更新时间
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ describe("toCreateRequest", () => {
|
|||||||
premise: " ",
|
premise: " ",
|
||||||
protagonist: "",
|
protagonist: "",
|
||||||
theme: "抗争",
|
theme: "抗争",
|
||||||
|
tone: "",
|
||||||
|
endingType: "",
|
||||||
|
narrativePov: "",
|
||||||
};
|
};
|
||||||
expect(toCreateRequest(form)).toEqual({
|
expect(toCreateRequest(form)).toEqual({
|
||||||
title: "逐光而行",
|
title: "逐光而行",
|
||||||
@@ -66,9 +69,34 @@ describe("toCreateRequest", () => {
|
|||||||
theme: "抗争",
|
theme: "抗争",
|
||||||
selling_points: ["逆袭", "系统流"],
|
selling_points: ["逆袭", "系统流"],
|
||||||
structure: "三幕",
|
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)是两个独立输入,
|
// QA H1 回归:立意(premise)与主角/金手指(protagonist)是两个独立输入,
|
||||||
// 不能互相覆盖——提交时各占一段合并进 premise。
|
// 不能互相覆盖——提交时各占一段合并进 premise。
|
||||||
it("merges premise and protagonist into premise without overwriting", () => {
|
it("merges premise and protagonist into premise without overwriting", () => {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export interface WizardForm {
|
|||||||
premise: string;
|
premise: string;
|
||||||
protagonist: string;
|
protagonist: string;
|
||||||
theme: string;
|
theme: string;
|
||||||
|
// 立项书级常量(灵感④):基调 / 结局取向 / 叙事视角。
|
||||||
|
tone: string;
|
||||||
|
endingType: string;
|
||||||
|
narrativePov: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WIZARD_STEPS = 5;
|
export const WIZARD_STEPS = 5;
|
||||||
@@ -32,11 +36,18 @@ export const emptyWizardForm: WizardForm = {
|
|||||||
premise: "",
|
premise: "",
|
||||||
protagonist: "",
|
protagonist: "",
|
||||||
theme: "",
|
theme: "",
|
||||||
|
tone: "",
|
||||||
|
endingType: "",
|
||||||
|
narrativePov: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"];
|
export const GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"];
|
||||||
export const STRUCTURES = ["三幕", "故事圈", "雪花"];
|
export const STRUCTURES = ["三幕", "故事圈", "雪花"];
|
||||||
export const SELLING_POINT_PRESETS = ["逆袭", "打脸", "系统流", "双男主", "群像"];
|
export const SELLING_POINT_PRESETS = ["逆袭", "打脸", "系统流", "双男主", "群像"];
|
||||||
|
// 立项书级常量预设(灵感④ / D 枚举)——free text 单选,命不中预设可留空。
|
||||||
|
export const TONES = ["热血", "轻松", "沉重", "治愈", "暗黑", "爽文"];
|
||||||
|
export const ENDING_TYPES = ["HE", "BE", "开放", "正剧"];
|
||||||
|
export const NARRATIVE_POVS = ["第一人称", "第三人称限知", "第三人称全知", "多视角"];
|
||||||
|
|
||||||
// 每一步可否前进:仅第 1 步(书名)必填,其余可空着继续(草稿式立项)。
|
// 每一步可否前进:仅第 1 步(书名)必填,其余可空着继续(草稿式立项)。
|
||||||
export function canAdvance(step: number, form: WizardForm): boolean {
|
export function canAdvance(step: number, form: WizardForm): boolean {
|
||||||
@@ -75,5 +86,8 @@ export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
|
|||||||
theme: trim(form.theme),
|
theme: trim(form.theme),
|
||||||
selling_points: form.sellingPoints,
|
selling_points: form.sellingPoints,
|
||||||
structure: trim(form.structure),
|
structure: trim(form.structure),
|
||||||
|
tone: trim(form.tone),
|
||||||
|
ending_type: trim(form.endingType),
|
||||||
|
narrative_pov: trim(form.narrativePov),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,6 +346,8 @@
|
|||||||
## 契约变更日志(append-only)
|
## 契约变更日志(append-only)
|
||||||
> 格式:`- [date] @skill 改 Cx:<改了什么> → 影响 <依赖方/任务>`
|
> 格式:`- [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-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/<name>.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 描述符字段不变)。
|
- [2026-06-24] @llm/@backend 立 C6-ext(Prompt 外置方案A):21 prompt 散文外置 `prompts/<name>.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 描述符字段不变)。
|
||||||
|
|||||||
@@ -418,6 +418,25 @@ async def test_no_genre_injects_no_fragment() -> None:
|
|||||||
assert "本作题材写法" not in ctx.stable_core
|
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 章) ----
|
# ---- 灵感①/F1:开篇黄金三章特判(volatile,仅前 3 章) ----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
from datetime import UTC, datetime
|
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:
|
def test_max_time_handles_missing_values() -> None:
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ class ProjectView:
|
|||||||
theme: str | None = None
|
theme: str | None = None
|
||||||
selling_points: list[Any] = field(default_factory=list)
|
selling_points: list[Any] = field(default_factory=list)
|
||||||
structure: str | None = None
|
structure: str | None = None
|
||||||
|
tone: str | None = None
|
||||||
|
ending_type: str | None = None
|
||||||
|
narrative_pov: str | None = None
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
pending_review_count: int = 0
|
pending_review_count: int = 0
|
||||||
|
|
||||||
@@ -44,6 +47,9 @@ class ProjectCreate:
|
|||||||
theme: str | None = None
|
theme: str | None = None
|
||||||
selling_points: list[Any] = field(default_factory=list)
|
selling_points: list[Any] = field(default_factory=list)
|
||||||
structure: str | None = None
|
structure: str | None = None
|
||||||
|
tone: str | None = None
|
||||||
|
ending_type: str | None = None
|
||||||
|
narrative_pov: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectRepo(Protocol):
|
class ProjectRepo(Protocol):
|
||||||
@@ -71,6 +77,9 @@ def _to_view(
|
|||||||
theme=row.theme,
|
theme=row.theme,
|
||||||
selling_points=list(row.selling_points or []),
|
selling_points=list(row.selling_points or []),
|
||||||
structure=row.structure,
|
structure=row.structure,
|
||||||
|
tone=row.tone,
|
||||||
|
ending_type=row.ending_type,
|
||||||
|
narrative_pov=row.narrative_pov,
|
||||||
updated_at=updated_at or row.updated_at,
|
updated_at=updated_at or row.updated_at,
|
||||||
pending_review_count=pending_review_count,
|
pending_review_count=pending_review_count,
|
||||||
)
|
)
|
||||||
@@ -92,6 +101,9 @@ class SqlProjectRepo:
|
|||||||
theme=data.theme,
|
theme=data.theme,
|
||||||
selling_points=list(data.selling_points),
|
selling_points=list(data.selling_points),
|
||||||
structure=data.structure,
|
structure=data.structure,
|
||||||
|
tone=data.tone,
|
||||||
|
ending_type=data.ending_type,
|
||||||
|
narrative_pov=data.narrative_pov,
|
||||||
)
|
)
|
||||||
self._s.add(row)
|
self._s.add(row)
|
||||||
await self._s.commit()
|
await self._s.commit()
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ class ProjectSpecView(BaseModel):
|
|||||||
theme: str | None = None
|
theme: str | None = None
|
||||||
# 题材(每书稳定项)——供 assemble 注入 genre-aware craft 片段(灵感 F1)。
|
# 题材(每书稳定项)——供 assemble 注入 genre-aware craft 片段(灵感 F1)。
|
||||||
genre: str | None = None
|
genre: str | None = None
|
||||||
|
# 立项书级常量(灵感④):基调/结局取向/叙事视角——每书稳定 → 入 stable_core 缓存前缀。
|
||||||
|
tone: str | None = None
|
||||||
|
ending_type: str | None = None
|
||||||
|
narrative_pov: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ---- Repository 协议(async;统一按 project_id 过滤)----
|
# ---- Repository 协议(async;统一按 project_id 过滤)----
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ def _build_spec_section(spec: ProjectSpecView | None) -> str | None:
|
|||||||
lines.append(f"【前提】{spec.premise}")
|
lines.append(f"【前提】{spec.premise}")
|
||||||
if spec.theme:
|
if spec.theme:
|
||||||
lines.append(f"【主题】{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))
|
return _section("作品蓝本", "\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,9 @@ class SqlProjectSpecRepo:
|
|||||||
premise=row.premise,
|
premise=row.premise,
|
||||||
theme=row.theme,
|
theme=row.theme,
|
||||||
genre=row.genre,
|
genre=row.genre,
|
||||||
|
tone=row.tone,
|
||||||
|
ending_type=row.ending_type,
|
||||||
|
narrative_pov=row.narrative_pov,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -48,6 +48,10 @@ class Project(UuidPk, TimestampedMixin, Base):
|
|||||||
theme: Mapped[str | None] = mapped_column(Text)
|
theme: Mapped[str | None] = mapped_column(Text)
|
||||||
selling_points: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
|
selling_points: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
|
||||||
structure: Mapped[str | None] = mapped_column(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):
|
class Character(UuidPk, CreatedAt, Base):
|
||||||
|
|||||||
Reference in New Issue
Block a user