feat: M1 — 立项→写章草稿(SSE)→自动保存;连一家 provider

- 薄自建 LLM 网关:OpenAI 兼容适配器(DeepSeek) + instructor 结构化输出 + usage_ledger 记账 + 档位路由
- 记忆服务 assemble:确定性选择(显式+主角+近况) + 渲染卡 + 缓存断点(中性文本)
- LangGraph 写章节点 + Postgres checkpointer + SSE 归一(token/done/error)
- API:立项 + 写章 draft(SSE) + PUT 自动保存 + 提供商凭据(Fernet 加密/测试连接)
- 前端:AppShell + 作品库 + 5 步立项向导 + 写作工作台(流式打字机+自动保存) + 设置页
- M1 E2E:真实 DB + mock 网关零 token 走通闭环
This commit is contained in:
Yaojia Wang
2026-06-18 11:38:28 +02:00
parent d3dc620a71
commit b523b4fd21
70 changed files with 6642 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
"""项目(立项)与章节草稿的请求/响应 schemaC3 / ARCH §7.2)。
snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
"""
from __future__ import annotations
import uuid
from typing import Any, Literal
from pydantic import BaseModel, Field
class ProjectCreateRequest(BaseModel):
"""POST /projects立项向导字段owner_id 由后端补 stub不入参"""
title: str = Field(min_length=1)
genre: str | None = None
logline: str | None = None
premise: str | None = None
theme: str | None = None
selling_points: list[Any] = Field(default_factory=list)
structure: str | None = None
class ProjectResponse(BaseModel):
"""项目视图(创建/列表/详情共用)。"""
id: uuid.UUID
title: str
genre: str | None = None
logline: str | None = None
premise: str | None = None
theme: str | None = None
selling_points: list[Any] = Field(default_factory=list)
structure: str | None = None
class ProjectListResponse(BaseModel):
"""GET /projects项目列表。"""
projects: list[ProjectResponse] = Field(default_factory=list)
class DraftSaveRequest(BaseModel):
"""PUT /projects/:id/chapters/:no/draft自动保存草稿正文。"""
text: str
class DraftResponse(BaseModel):
"""草稿保存结果(脱敏:只回元信息 + 长度,不回灌正文以外的衍生)。"""
project_id: uuid.UUID
chapter_no: int
volume: int
status: str
version: int
length: int
# ---- 审稿T2.5----
class ReviewRequest(BaseModel):
"""POST /projects/:id/chapters/:no/review可选携带待审草稿正文。
不传 `draft` 时端点回退到已保存的草稿chapter_repo
"""
draft: str | None = None
class ReviewHistoryItem(BaseModel):
"""单条审稿留痕GET .../reviews 历史项snake_case"""
id: uuid.UUID
project_id: uuid.UUID
chapter_no: int
chapter_version: int | None = None
conflicts: list[dict[str, Any]] = Field(default_factory=list)
foreshadow_sug: list[dict[str, Any]] = Field(default_factory=list)
style: dict[str, Any] | None = None
pace: dict[str, Any] | None = None
health_score: int | None = None
decisions: dict[str, Any] | None = None
class ReviewHistoryResponse(BaseModel):
"""GET /projects/:id/chapters/:no/reviews审稿历史新→旧"""
reviews: list[ReviewHistoryItem] = Field(default_factory=list)
# ---- 验收T2.4----
# 每个冲突的裁决:采纳改法 / 忽略 / 手改R5
Verdict = Literal["accept", "ignore", "manual"]
class ConflictDecision(BaseModel):
"""对最近一次审稿留痕里**某个冲突**(按其在 conflicts 列表的下标定位)的裁决。"""
conflict_index: int = Field(ge=0, description="冲突在最近审稿 conflicts 列表中的下标")
verdict: Verdict = Field(description="采纳改法 / 忽略 / 手改")
note: str | None = Field(default=None, description="可选裁决备注(如手改说明)")
class AcceptRequest(BaseModel):
"""POST /projects/:id/chapters/:no/accept裁决清单 + 可能改过的终稿。"""
final_text: str = Field(min_length=1, description="作者裁决/改稿后的最终验收文本")
decisions: list[ConflictDecision] = Field(
default_factory=list, description="对最近审稿每个冲突的裁决每冲突必有其一R5"
)
class AcceptResponse(BaseModel):
"""验收「本次将更新」清单ARCH §7.2 写回结果)。"""
project_id: uuid.UUID
chapter_no: int
accepted_version: int = Field(description="晋升到的 accepted 版次max+1")
digest_added: bool = Field(description="是否新增了一行 chapter_digests")
decisions_recorded: int = Field(description="本次写回的裁决条数")
review_id: uuid.UUID | None = Field(default=None, description="写回裁决的审稿留痕行 id")

View File

@@ -0,0 +1,76 @@
"""提供商凭据/档位路由的请求/响应 schemaC3 / ARCH §4.7, §7.2)。
snake_case响应一律 **掩码 Key**,绝不含明文。前端经 OpenAPI 生成 TS 类型消费。
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class ProviderView(BaseModel):
"""已配置提供商(掩码视图)。"""
provider: str
masked_key: str # 形如 sk-…1234明文永不出现
class TierRoutingView(BaseModel):
"""档位 → provider:model 路由(含回退链)。"""
tier: str
provider: str
model: str
fallback: list[str] = Field(default_factory=list)
class ProvidersResponse(BaseModel):
"""GET/PUT 响应:已配置提供商(掩码)+ 当前档位路由。"""
providers: list[ProviderView] = Field(default_factory=list)
tier_routing: list[TierRoutingView] = Field(default_factory=list)
class ProviderCredentialInput(BaseModel):
"""单条提供商凭据写入。"""
provider: str = Field(min_length=1)
api_key: str = Field(min_length=1) # 明文入站,加密入库,绝不回显
class TierRoutingInput(BaseModel):
"""单条档位路由写入。"""
tier: str = Field(min_length=1)
provider: str = Field(min_length=1)
model: str = Field(min_length=1)
fallback: list[str] = Field(default_factory=list)
class ProvidersUpsertRequest(BaseModel):
"""PUT 请求:可同时 upsert 若干凭据与档位路由(幂等)。"""
credentials: list[ProviderCredentialInput] = Field(default_factory=list)
tier_routing: list[TierRoutingInput] = Field(default_factory=list)
class TestConnectionRequest(BaseModel):
"""POST /test最小探测请求。"""
provider: str = Field(min_length=1)
class CapabilitiesView(BaseModel):
"""探测得到的能力矩阵(镜像网关 `Capabilities`)。"""
structured_output: bool = False
prefix_cache: bool = False
thinking: bool = False
class TestConnectionResponse(BaseModel):
"""POST /test 响应:连通性 + 能力矩阵。"""
provider: str
ok: bool
capabilities: CapabilitiesView