feat(ux): T4-b 后端 — draft 端点接收本章指令 directive 直通 assemble(volatile)
assemble/_build_volatile 加 directive 参数(仅入 volatile,守缓存前缀不变量 #9);stream_draft 加可选 body {directive} 透传;不持久化、无迁移;directive 长度入 log 不记原文。新 DraftStreamRequest schema + 重生成 TS 客户端。后端门禁绿: ruff/mypy/pytest。契约记 memory/contracts.md。
This commit is contained in:
@@ -196,6 +196,33 @@ async def test_draft_stream_yields_sse_tokens_and_done() -> None:
|
|||||||
assert gateway.requests[0].tier == "writer"
|
assert gateway.requests[0].tier == "writer"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_stream_threads_directive_into_volatile() -> None:
|
||||||
|
gateway = FakeWriterGateway(chunks=["正文"])
|
||||||
|
client, _, _, _ = _make_client(gateway=gateway)
|
||||||
|
pid = uuid.uuid4()
|
||||||
|
async with client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"/projects/{pid}/chapters/1/draft", json={"directive": "多写战斗"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(gateway.requests) == 1
|
||||||
|
# 指令直通 assemble→volatile(write 节点把 volatile 放进 LlmRequest.input)。
|
||||||
|
assert "多写战斗" in str(gateway.requests[0].input)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_draft_stream_backward_compatible_without_body() -> None:
|
||||||
|
gateway = FakeWriterGateway(chunks=["正文"])
|
||||||
|
client, _, _, _ = _make_client(gateway=gateway)
|
||||||
|
pid = uuid.uuid4()
|
||||||
|
async with client:
|
||||||
|
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(gateway.requests) == 1
|
||||||
|
assert "本章指令" not in str(gateway.requests[0].input)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_draft_stream_maps_error_to_sse_error_event() -> None:
|
async def test_draft_stream_maps_error_to_sse_error_event() -> None:
|
||||||
from ww_shared import AppError
|
from ww_shared import AppError
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from ww_api.schemas.projects import (
|
|||||||
AcceptResponse,
|
AcceptResponse,
|
||||||
DraftResponse,
|
DraftResponse,
|
||||||
DraftSaveRequest,
|
DraftSaveRequest,
|
||||||
|
DraftStreamRequest,
|
||||||
DraftView,
|
DraftView,
|
||||||
ProjectCreateRequest,
|
ProjectCreateRequest,
|
||||||
ProjectListResponse,
|
ProjectListResponse,
|
||||||
@@ -240,11 +241,17 @@ async def stream_draft(
|
|||||||
gateway: GatewayDep,
|
gateway: GatewayDep,
|
||||||
injection_repo: InjectionRepoDep,
|
injection_repo: InjectionRepoDep,
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
body: DraftStreamRequest | None = None,
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
"""流式写章草稿:组装记忆(含作者注入覆盖)→ 网关流 → 归一为 SSE 事件 → text/event-stream。"""
|
"""流式写章草稿:组装记忆(含作者注入覆盖 + 本章指令)→ 网关流 → 归一 SSE → event-stream。
|
||||||
|
|
||||||
|
`body.directive`(可选,T4-b)是临时本章指令,直通 assemble→volatile(不持久化);
|
||||||
|
无 body 的旧调用方仍可用(向后兼容)。
|
||||||
|
"""
|
||||||
request_id = getattr(request.state, "request_id", None)
|
request_id = getattr(request.state, "request_id", None)
|
||||||
|
directive = body.directive if body else None
|
||||||
override = await injection_repo.get(project_id, chapter_no)
|
override = await injection_repo.get(project_id, chapter_no)
|
||||||
context = await assemble(repos, project_id, chapter_no, override=override)
|
context = await assemble(repos, project_id, chapter_no, override=override, directive=directive)
|
||||||
log.info(
|
log.info(
|
||||||
"draft_stream_start",
|
"draft_stream_start",
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
@@ -252,6 +259,7 @@ async def stream_draft(
|
|||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
stable_core_len=len(context.stable_core),
|
stable_core_len=len(context.stable_core),
|
||||||
volatile_len=len(context.volatile),
|
volatile_len=len(context.volatile),
|
||||||
|
directive_len=len(body.directive) if body and body.directive else 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
deltas = stream_chapter_draft(
|
deltas = stream_chapter_draft(
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ class ProjectListResponse(BaseModel):
|
|||||||
projects: list[ProjectResponse] = Field(default_factory=list)
|
projects: list[ProjectResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DraftStreamRequest(BaseModel):
|
||||||
|
"""POST /projects/:id/chapters/:no/draft:本章生成的可选输入(T4-b)。
|
||||||
|
|
||||||
|
`directive` = 作者本章指令(覆盖/补充大纲节拍)。临时输入,不持久化、无迁移;
|
||||||
|
后端只入 volatile(守缓存前缀不变量 #9)。无 body 时退化为纯按大纲生成(向后兼容)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
directive: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class DraftSaveRequest(BaseModel):
|
class DraftSaveRequest(BaseModel):
|
||||||
"""PUT /projects/:id/chapters/:no/draft:自动保存草稿正文。"""
|
"""PUT /projects/:id/chapters/:no/draft:自动保存草稿正文。"""
|
||||||
|
|
||||||
|
|||||||
22
apps/web/lib/api/schema.d.ts
vendored
22
apps/web/lib/api/schema.d.ts
vendored
@@ -142,7 +142,10 @@ export interface paths {
|
|||||||
put: operations["save_draft_projects__project_id__chapters__chapter_no__draft_put"];
|
put: operations["save_draft_projects__project_id__chapters__chapter_no__draft_put"];
|
||||||
/**
|
/**
|
||||||
* Stream Draft
|
* Stream Draft
|
||||||
* @description 流式写章草稿:组装记忆(含作者注入覆盖)→ 网关流 → 归一为 SSE 事件 → text/event-stream。
|
* @description 流式写章草稿:组装记忆(含作者注入覆盖 + 本章指令)→ 网关流 → 归一 SSE → event-stream。
|
||||||
|
*
|
||||||
|
* `body.directive`(可选,T4-b)是临时本章指令,直通 assemble→volatile(不持久化);
|
||||||
|
* 无 body 的旧调用方仍可用(向后兼容)。
|
||||||
*/
|
*/
|
||||||
post: operations["stream_draft_projects__project_id__chapters__chapter_no__draft_post"];
|
post: operations["stream_draft_projects__project_id__chapters__chapter_no__draft_post"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -839,6 +842,17 @@ export interface components {
|
|||||||
/** Text */
|
/** Text */
|
||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* DraftStreamRequest
|
||||||
|
* @description POST /projects/:id/chapters/:no/draft:本章生成的可选输入(T4-b)。
|
||||||
|
*
|
||||||
|
* `directive` = 作者本章指令(覆盖/补充大纲节拍)。临时输入,不持久化、无迁移;
|
||||||
|
* 后端只入 volatile(守缓存前缀不变量 #9)。无 body 时退化为纯按大纲生成(向后兼容)。
|
||||||
|
*/
|
||||||
|
DraftStreamRequest: {
|
||||||
|
/** Directive */
|
||||||
|
directive?: string | null;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* DraftView
|
* DraftView
|
||||||
* @description GET .../draft:回灌已保存草稿(含正文,供工作台重载编辑器)。
|
* @description GET .../draft:回灌已保存草稿(含正文,供工作台重载编辑器)。
|
||||||
@@ -1848,7 +1862,11 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody?: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DraftStreamRequest"] | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
/** @description Successful Response */
|
/** @description Successful Response */
|
||||||
200: {
|
200: {
|
||||||
|
|||||||
@@ -162,6 +162,11 @@
|
|||||||
- **复用读 seam**:直接调既有 `chapter_repo.get_draft(project_id, chapter_no)->ChapterDraftView|None`(同续审 `_resolve_review_draft` 的读缝);`ChapterDraftView` 已含全部字段,无需新 repo 方法/新 view。多版本时固定返草稿版次(`DRAFT_VERSION=1`)那条可编辑工作副本,与 PUT 保存的同一行。
|
- **复用读 seam**:直接调既有 `chapter_repo.get_draft(project_id, chapter_no)->ChapterDraftView|None`(同续审 `_resolve_review_draft` 的读缝);`ChapterDraftView` 已含全部字段,无需新 repo 方法/新 view。多版本时固定返草稿版次(`DRAFT_VERSION=1`)那条可编辑工作副本,与 PUT 保存的同一行。
|
||||||
- **@frontend 行动**:`pnpm gen:api` 纳入 `GET /projects/:id/chapters/:no/draft`(新 schema `DraftView`);工作台初次加载拉已保存草稿回灌编辑器(去掉「重访看似未保存」局限)。
|
- **@frontend 行动**:`pnpm gen:api` 纳入 `GET /projects/:id/chapters/:no/draft`(新 schema `DraftView`);工作台初次加载拉已保存草稿回灌编辑器(去掉「重访看似未保存」局限)。
|
||||||
|
|
||||||
|
### C3 扩展(T4-b, 2026-06-20)· `POST .../draft` 接收可选本章指令 `directive`(挂既有 `routers/projects.py`) owner @backend 状态: 稳定
|
||||||
|
- **仅加可选 body,向后兼容**:`POST /projects/:id/chapters/:no/draft` 现接收可选 JSON body `DraftStreamRequest{directive: str|None=None}`(无 body 的旧调用方不变,仍按大纲生成)。`directive`=作者本章指令,**临时输入、不持久化、无迁移**。
|
||||||
|
- 直通 `assemble(..., directive=…)`→`_build_volatile` 以「本章指令」段领衔 volatile(断点后),**绝不入 stable_core**(守缓存前缀不变量 #9)。`draft_stream_start` 日志加 `directive_len`(不记原文,脱敏)。
|
||||||
|
- **@frontend 行动**:`pnpm gen:api` 纳入 `DraftStreamRequest`;`useDraftStream.start(projectId, chapterNo, directive?)` 非空时 POST JSON body 传 directive。
|
||||||
|
|
||||||
### C3 扩展(K1.3, 2026-06-19)· Kimi Code OAuth device-flow 端点(独立 `routers/kimi_oauth.py`,已注册) owner @backend 状态: 稳定
|
### C3 扩展(K1.3, 2026-06-19)· Kimi Code OAuth device-flow 端点(独立 `routers/kimi_oauth.py`,已注册) owner @backend 状态: 稳定
|
||||||
- snake_case;新增 3 端点 + 3 schema → **@frontend 必须 `cd apps/web && pnpm gen:api`**(K1.4 前置)。**响应/job 结果/日志绝不含 access/refresh token**(只 user_code/connected/expires_at 等非密信息);token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`。
|
- snake_case;新增 3 端点 + 3 schema → **@frontend 必须 `cd apps/web && pnpm gen:api`**(K1.4 前置)。**响应/job 结果/日志绝不含 access/refresh token**(只 user_code/connected/expires_at 等非密信息);token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`。
|
||||||
- `POST /settings/providers/kimi-code/oauth/start` → **202** `OAuthStartResponse{job_id:uuid, user_code:str, verification_uri:str, verification_uri_complete:str|None, expires_in:int, interval:int}`。流程:`start_device_authorization(http)` → `job_repo.create(None,"kimi_oauth")` + `session.commit()`(202 前持久化供轮询)→ `background_tasks.add_task(run_job, session_factory, job.id, _make_poll_work(device))`。后台 `work` 在 `run_job` 自建独立 session 上**循环 `poll_token`**(`authorization_pending`→续轮询、`slow_down`→增大 interval、`expired`/`denied`→抛 AppError 置 job failed);成功 → `upsert_oauth_credential`(加密包)+ job done(结果只 `{connected:true, provider}`)。前端展示 user_code + 打开 verification_uri + 轮询 `GET /jobs/{id}`。
|
- `POST /settings/providers/kimi-code/oauth/start` → **202** `OAuthStartResponse{job_id:uuid, user_code:str, verification_uri:str, verification_uri_complete:str|None, expires_in:int, interval:int}`。流程:`start_device_authorization(http)` → `job_repo.create(None,"kimi_oauth")` + `session.commit()`(202 前持久化供轮询)→ `background_tasks.add_task(run_job, session_factory, job.id, _make_poll_work(device))`。后台 `work` 在 `run_job` 自建独立 session 上**循环 `poll_token`**(`authorization_pending`→续轮询、`slow_down`→增大 interval、`expired`/`denied`→抛 AppError 置 job failed);成功 → `upsert_oauth_credential`(加密包)+ job done(结果只 `{connected:true, provider}`)。前端展示 user_code + 打开 verification_uri + 轮询 `GET /jobs/{id}`。
|
||||||
|
|||||||
@@ -293,6 +293,18 @@ async def test_assemble_override_recent_n_limits_digests() -> None:
|
|||||||
assert "实体1" not in names
|
assert "实体1" not in names
|
||||||
|
|
||||||
|
|
||||||
|
async def test_assemble_directive_in_volatile_not_stable() -> None:
|
||||||
|
repos = build_repos(
|
||||||
|
characters=[char("林动", role="主角")],
|
||||||
|
spec=ProjectSpecView(title="书"),
|
||||||
|
)
|
||||||
|
ctx = await assemble(repos, PROJECT, 1, directive="多写战斗")
|
||||||
|
assert "本章指令" in ctx.volatile
|
||||||
|
assert "多写战斗" in ctx.volatile
|
||||||
|
assert "多写战斗" not in ctx.stable_core
|
||||||
|
assert "本章指令" not in ctx.stable_core
|
||||||
|
|
||||||
|
|
||||||
# ---- render_cards ----
|
# ---- render_cards ----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ def _build_volatile(
|
|||||||
foreshadows: list[ForeshadowView],
|
foreshadows: list[ForeshadowView],
|
||||||
digests: list[DigestView],
|
digests: list[DigestView],
|
||||||
beats: dict[str, Any],
|
beats: dict[str, Any],
|
||||||
|
directive: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
fore_lines = [
|
fore_lines = [
|
||||||
f"【伏笔】{f.code} {f.title} [{f.status}]"
|
f"【伏笔】{f.code} {f.title} [{f.status}]"
|
||||||
@@ -113,6 +114,8 @@ def _build_volatile(
|
|||||||
f"第{d.chapter_no}章:{_ser(d.facts)}" for d in sorted(digests, key=lambda d: d.chapter_no)
|
f"第{d.chapter_no}章:{_ser(d.facts)}" for d in sorted(digests, key=lambda d: d.chapter_no)
|
||||||
]
|
]
|
||||||
sections = [
|
sections = [
|
||||||
|
# 作者本章指令领衔——易变的每章输入,只入 volatile(断点后),守缓存前缀不变量 #9。
|
||||||
|
_section("本章指令", directive.strip()) if directive and directive.strip() else None,
|
||||||
# 写章指令始终在场——保证 volatile(断点后的 input)永不为空(修空 prompt 400)。
|
# 写章指令始终在场——保证 volatile(断点后的 input)永不为空(修空 prompt 400)。
|
||||||
_section("写作指令", f"请创作第 {chapter_no} 章的正文。"),
|
_section("写作指令", f"请创作第 {chapter_no} 章的正文。"),
|
||||||
_section("本章注入卡片", cards),
|
_section("本章注入卡片", cards),
|
||||||
@@ -130,11 +133,15 @@ async def assemble(
|
|||||||
recent_k: int = RECENT_DIGEST_COUNT,
|
recent_k: int = RECENT_DIGEST_COUNT,
|
||||||
*,
|
*,
|
||||||
override: InjectionOverride | None = None,
|
override: InjectionOverride | None = None,
|
||||||
|
directive: str | None = None,
|
||||||
) -> AssembledContext:
|
) -> AssembledContext:
|
||||||
"""组装本章 prompt 上下文(确定性、可缓存)。
|
"""组装本章 prompt 上下文(确定性、可缓存)。
|
||||||
|
|
||||||
`override`(B0 可控版作者覆盖)也是确定性输入:pin 强制纳入 / excluded 强制剔除 /
|
`override`(B0 可控版作者覆盖)也是确定性输入:pin 强制纳入 / excluded 强制剔除 /
|
||||||
recent_n 覆盖近况回看章数。draft 与注入读端点传同一覆盖,故「看到的=写章用的」(不变量 #6)。
|
recent_n 覆盖近况回看章数。draft 与注入读端点传同一覆盖,故「看到的=写章用的」(不变量 #6)。
|
||||||
|
|
||||||
|
`directive`(T4-b 本章指令)是临时的每章输入——只入 volatile(断点后),绝不污染
|
||||||
|
stable_core 缓存前缀(不变量 #9);不持久化。
|
||||||
"""
|
"""
|
||||||
# recent_n 覆盖优先于默认 recent_k;非正值视为未设(守住至少回看若干章)。
|
# recent_n 覆盖优先于默认 recent_k;非正值视为未设(守住至少回看若干章)。
|
||||||
effective_k = override.recent_n if (override and override.recent_n) else recent_k
|
effective_k = override.recent_n if (override and override.recent_n) else recent_k
|
||||||
@@ -171,6 +178,8 @@ async def assemble(
|
|||||||
|
|
||||||
stable_core = _build_stable(spec, world_entities, main_characters, style, rules)
|
stable_core = _build_stable(spec, world_entities, main_characters, style, rules)
|
||||||
cards = render_cards(selection, characters, world_entities)
|
cards = render_cards(selection, characters, world_entities)
|
||||||
volatile = _build_volatile(chapter_no, cards, foreshadows, recent_digests, outline.beats)
|
volatile = _build_volatile(
|
||||||
|
chapter_no, cards, foreshadows, recent_digests, outline.beats, directive
|
||||||
|
)
|
||||||
|
|
||||||
return AssembledContext(stable_core=stable_core, volatile=volatile, selection=selection)
|
return AssembledContext(stable_core=stable_core, volatile=volatile, selection=selection)
|
||||||
|
|||||||
Reference in New Issue
Block a user