Skip to content

Commit 3e640d8

Browse files
papysansclaude
andcommitted
fix(api): 收口 one-shot 契约并接通 scope 提示
Reject legacy one-shot request fields so batch_direction is the only accepted story-direction input, and carry scope into outline generation without changing prompt-preview semantics. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 68004a6 commit 3e640d8

File tree

3 files changed

+101
-60
lines changed

3 files changed

+101
-60
lines changed

backend/api/main.py

Lines changed: 51 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@
1414
from enum import Enum
1515
from datetime import datetime, timezone
1616
from pathlib import Path
17-
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
17+
from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple
1818
from uuid import uuid4, uuid5, NAMESPACE_URL
1919

2020
from fastapi import FastAPI, HTTPException, Request, UploadFile, File
2121
from fastapi.middleware.cors import CORSMiddleware
2222
from fastapi.responses import FileResponse, StreamingResponse
2323
from starlette.background import BackgroundTask
2424
from starlette.middleware.trustedhost import TrustedHostMiddleware
25-
from pydantic import BaseModel, Field, model_validator
25+
from pydantic import BaseModel, ConfigDict, Field, model_validator
2626
from pydantic_settings import BaseSettings, SettingsConfigDict
2727

2828
from agents.studio import AGENT_PROMPTS, AgentStudio, StudioWorkflow
@@ -1650,6 +1650,7 @@ def build_outline_messages(
16501650
identity: str,
16511651
continuation_mode: bool = False,
16521652
batch_direction: Optional[str] = None,
1653+
scope: Optional[str] = None,
16531654
) -> List[Dict[str, str]]:
16541655
phase_hints = build_outline_phase_hints(chapter_count, continuation_mode)
16551656
constraints = [
@@ -1664,7 +1665,38 @@ def build_outline_messages(
16641665
]
16651666
if continuation_mode:
16661667
constraints.extend(build_serial_continuation_constraints())
1667-
result = [
1668+
payload = {
1669+
"task": "根据一句话梗概拆成章节蓝图",
1670+
"chapter_count": chapter_count,
1671+
"prompt": prompt,
1672+
"genre": project.genre,
1673+
"style": project.style,
1674+
"identity": identity,
1675+
"continuation_mode": continuation_mode,
1676+
"scope": scope,
1677+
"constraints": constraints,
1678+
"forbidden_title_keywords": [
1679+
"起势递进",
1680+
"代价扩张",
1681+
"阶段收束",
1682+
"里程碑",
1683+
"第二阶段钩子",
1684+
],
1685+
"title_style_examples": [
1686+
"镜城残响",
1687+
"第二次心跳",
1688+
"车祸后的合法复活",
1689+
"监控者的真面目",
1690+
"在雪夜醒来的那个人",
1691+
],
1692+
"phase_hints": phase_hints,
1693+
}
1694+
if batch_direction:
1695+
payload["batch_direction_guidance"] = (
1696+
f"用户对这批章节的创作方向要求:{batch_direction}。请在拆章和内容生成时参考此方向。"
1697+
)
1698+
1699+
return [
16681700
{
16691701
"role": "system",
16701702
"content": (
@@ -1675,41 +1707,9 @@ def build_outline_messages(
16751707
},
16761708
{
16771709
"role": "user",
1678-
"content": json.dumps(
1679-
{
1680-
"task": "根据一句话梗概拆成章节蓝图",
1681-
"chapter_count": chapter_count,
1682-
"prompt": prompt,
1683-
"genre": project.genre,
1684-
"style": project.style,
1685-
"identity": identity,
1686-
"continuation_mode": continuation_mode,
1687-
"constraints": constraints,
1688-
"forbidden_title_keywords": [
1689-
"起势递进",
1690-
"代价扩张",
1691-
"阶段收束",
1692-
"里程碑",
1693-
"第二阶段钩子",
1694-
],
1695-
"title_style_examples": [
1696-
"镜城残响",
1697-
"第二次心跳",
1698-
"车祸后的合法复活",
1699-
"监控者的真面目",
1700-
"在雪夜醒来的那个人",
1701-
],
1702-
"phase_hints": phase_hints,
1703-
},
1704-
ensure_ascii=False,
1705-
),
1710+
"content": json.dumps(payload, ensure_ascii=False),
17061711
},
17071712
]
1708-
if batch_direction:
1709-
result[-1]["content"] += (
1710-
f"\n\n用户对这批章节的创作方向要求:{batch_direction}\n请在拆章和内容生成时参考此方向。"
1711-
)
1712-
return result
17131713

17141714

17151715
def build_chapter_outline(
@@ -1723,6 +1723,7 @@ def build_chapter_outline(
17231723
start_chapter_number: int = 1,
17241724
existing_titles: Optional[List[str]] = None,
17251725
batch_direction: Optional[str] = None,
1726+
scope: Optional[str] = None,
17261727
) -> List[Dict[str, str]]:
17271728
identity = store.three_layer.get_identity()[:2500]
17281729
messages = build_outline_messages(
@@ -1732,6 +1733,7 @@ def build_chapter_outline(
17321733
identity=identity,
17331734
continuation_mode=continuation_mode,
17341735
batch_direction=batch_direction,
1736+
scope=scope,
17351737
)
17361738
raw = studio.llm_client.chat(
17371739
messages,
@@ -2834,20 +2836,15 @@ class ChapterDirectionRequest(BaseModel):
28342836

28352837

28362838
class OneShotBookRequest(BaseModel):
2837-
prompt: str = ""
2838-
batch_direction: Optional[str] = None
2839+
model_config = ConfigDict(extra="forbid")
2840+
2841+
batch_direction: str = Field(min_length=1)
28392842
mode: GenerationMode = GenerationMode.STUDIO
28402843
chapter_count: Optional[int] = Field(default=None, ge=1, le=60)
28412844
words_per_chapter: int = Field(default=1600, ge=300, le=12000)
28422845
auto_approve: bool = False
28432846
continuation_mode: bool = False
2844-
scope: Optional[str] = None
2845-
2846-
@model_validator(mode="after")
2847-
def apply_batch_direction_alias(self):
2848-
if not self.prompt and self.batch_direction:
2849-
self.prompt = self.batch_direction
2850-
return self
2847+
scope: Optional[Literal["volume", "book"]] = None
28512848

28522849

28532850
class PromptPreviewRequest(BaseModel):
@@ -4262,7 +4259,7 @@ async def run_one_shot_book_generation(
42624259
)
42634260
outline = await asyncio.to_thread(
42644261
build_chapter_outline,
4265-
prompt=req.prompt.strip(),
4262+
prompt=req.batch_direction.strip(),
42664263
chapter_count=chapter_count,
42674264
project=project,
42684265
store=store,
@@ -4271,6 +4268,7 @@ async def run_one_shot_book_generation(
42714268
start_chapter_number=next_number,
42724269
existing_titles=existing_titles,
42734270
batch_direction=req.batch_direction,
4271+
scope=req.scope,
42744272
)
42754273
await emit_progress(
42764274
progress,
@@ -4617,7 +4615,7 @@ async def book_stage_callback(
46174615
response_payload = {
46184616
"project_id": project_id,
46194617
"mode": req.mode.value,
4620-
"prompt": req.prompt.strip(),
4618+
"prompt": req.batch_direction.strip(),
46214619
"continuation_mode": req.continuation_mode,
46224620
"generated_chapters": len(created),
46234621
"chapters": created,
@@ -4633,9 +4631,9 @@ async def generate_one_shot_book(project_id: str, req: OneShotBookRequest):
46334631
if not project:
46344632
raise HTTPException(status_code=404, detail="Project not found")
46354633

4636-
prompt = req.prompt.strip()
4637-
if not prompt:
4638-
raise HTTPException(status_code=400, detail="prompt is required")
4634+
direction = req.batch_direction.strip()
4635+
if not direction:
4636+
raise HTTPException(status_code=400, detail="batch_direction is required")
46394637

46404638
store = get_or_create_store(project_id)
46414639
studio = get_or_create_studio(project_id)
@@ -4664,9 +4662,9 @@ async def generate_one_shot_book_stream(project_id: str, req: OneShotBookRequest
46644662
if not project:
46654663
raise HTTPException(status_code=404, detail="Project not found")
46664664

4667-
prompt = req.prompt.strip()
4668-
if not prompt:
4669-
raise HTTPException(status_code=400, detail="prompt is required")
4665+
direction = req.batch_direction.strip()
4666+
if not direction:
4667+
raise HTTPException(status_code=400, detail="batch_direction is required")
46704668

46714669
store = get_or_create_store(project_id)
46724670
studio = get_or_create_studio(project_id)

backend/tests/test_api_smoke.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,7 +1135,7 @@ def test_one_shot_book_generation(self):
11351135
batch_res = self.client.post(
11361136
f"/api/projects/{project_id}/one-shot-book",
11371137
json={
1138-
"prompt": "主角在雪夜被背叛后潜伏反击,最终揪出幕后主使。",
1138+
"batch_direction": "主角在雪夜被背叛后潜伏反击,最终揪出幕后主使。",
11391139
"mode": "quick",
11401140
"chapter_count": 3,
11411141
"words_per_chapter": 700,
@@ -1157,7 +1157,7 @@ def test_one_shot_book_continuation_mode_uses_next_chapter_number(self):
11571157
res = self.client.post(
11581158
f"/api/projects/{project_id}/one-shot-book",
11591159
json={
1160-
"prompt": "续写主线并留钩子。",
1160+
"batch_direction": "续写主线并留钩子。",
11611161
"mode": "quick",
11621162
"chapter_count": 1,
11631163
"words_per_chapter": 700,
@@ -1175,7 +1175,7 @@ def test_one_shot_book_stream_generation(self):
11751175
"POST",
11761176
f"/api/projects/{project_id}/one-shot-book/stream",
11771177
json={
1178-
"prompt": "主角在雪夜被背叛后潜伏反击,最终揪出幕后主使。",
1178+
"batch_direction": "主角在雪夜被背叛后潜伏反击,最终揪出幕后主使。",
11791179
"mode": "quick",
11801180
"chapter_count": 1,
11811181
"words_per_chapter": 700,
@@ -1559,6 +1559,24 @@ def test_build_outline_messages_includes_continuation_constraints(self):
15591559
self.assertIn("阶段收束", forbidden)
15601560
self.assertIn("第二阶段钩子", forbidden)
15611561

1562+
def test_build_outline_messages_include_scope_and_batch_direction(self):
1563+
project_id = self._create_project()
1564+
project = projects[project_id]
1565+
1566+
messages = build_outline_messages(
1567+
prompt="雪夜复仇",
1568+
chapter_count=4,
1569+
project=project,
1570+
identity="IDENTITY",
1571+
continuation_mode=False,
1572+
batch_direction="主角潜伏反击",
1573+
scope="volume",
1574+
)
1575+
1576+
payload = json.loads(messages[1]["content"])
1577+
self.assertEqual(payload["scope"], "volume")
1578+
self.assertIn("主角潜伏反击", messages[1]["content"])
1579+
15621580
def test_build_fallback_outline_avoids_phase_template_titles(self):
15631581
outline = build_fallback_outline(
15641582
prompt="开始在10章内收束阶段里程碑,但是不要写死,记得抛出第二阶段钩子",

backend/tests/test_v2_semantics.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,11 @@ def test_one_shot_book_accepts_scope_field(self):
103103
)
104104
self.assertEqual(batch_res.status_code, 200)
105105

106-
def test_one_shot_book_ignores_unknown_start_chapter_number(self):
106+
def test_one_shot_book_rejects_legacy_prompt_field(self):
107107
create_res = self.client.post(
108108
"/api/projects",
109109
json={
110-
"name": f"旧起始章项目-{uuid4().hex[:8]}",
110+
"name": f"旧prompt项目-{uuid4().hex[:8]}",
111111
"genre": "奇幻",
112112
"style": "冷峻",
113113
"target_length": 300000,
@@ -120,14 +120,39 @@ def test_one_shot_book_ignores_unknown_start_chapter_number(self):
120120
batch_res = self.client.post(
121121
f"/api/projects/{project_id}/one-shot-book",
122122
json={
123-
"batch_direction": "测试未知字段被忽略。",
123+
"prompt": "旧字段",
124+
"mode": "quick",
125+
"chapter_count": 1,
126+
"words_per_chapter": 700,
127+
},
128+
)
129+
self.assertEqual(batch_res.status_code, 422)
130+
131+
def test_one_shot_book_rejects_unknown_fields(self):
132+
create_res = self.client.post(
133+
"/api/projects",
134+
json={
135+
"name": f"未知字段项目-{uuid4().hex[:8]}",
136+
"genre": "奇幻",
137+
"style": "冷峻",
138+
"target_length": 300000,
139+
"taboo_constraints": [],
140+
},
141+
)
142+
self.assertEqual(create_res.status_code, 200)
143+
project_id = create_res.json()["id"]
144+
145+
batch_res = self.client.post(
146+
f"/api/projects/{project_id}/one-shot-book",
147+
json={
148+
"batch_direction": "测试未知字段被拒绝。",
124149
"mode": "quick",
125150
"chapter_count": 1,
126151
"words_per_chapter": 700,
127152
"start_chapter_number": 99,
128153
},
129154
)
130-
self.assertEqual(batch_res.status_code, 200)
155+
self.assertEqual(batch_res.status_code, 422)
131156

132157

133158
if __name__ == "__main__":

0 commit comments

Comments
 (0)