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,227 @@
"""内存替身:项目/章节 repo + 写章网关(端点测试用,无 DB/无网络)。
绝对导入 `from fakes_projects import ...`(包目录无 __init__.py见 memory/gotchas
全局唯一名(避免跨包同名碰撞)。
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from typing import Any
from pydantic import BaseModel
from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView
from ww_core.domain.project_repo import ProjectCreate, ProjectView
from ww_core.domain.repositories import DigestView
from ww_core.domain.review_repo import ReviewView
from ww_llm_gateway.types import Delta, LlmRequest, LlmResponse, ServedBy, Usage
class FakeProjectRepo:
"""实现 `ProjectRepo` Protocol 的内存版(按 owner_id 隔离)。"""
def __init__(self) -> None:
self.rows: dict[uuid.UUID, tuple[uuid.UUID, ProjectView]] = {}
async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView:
pid = uuid.uuid4()
view = ProjectView(
id=pid,
title=data.title,
genre=data.genre,
logline=data.logline,
premise=data.premise,
theme=data.theme,
selling_points=list(data.selling_points),
structure=data.structure,
)
self.rows[pid] = (owner_id, view)
return view
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
return [v for (o, v) in self.rows.values() if o == owner_id]
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
entry = self.rows.get(project_id)
if entry is None or entry[0] != owner_id:
return None
return entry[1]
class FakeChapterRepo:
"""实现 `ChapterRepo` Protocol幂等覆盖同 (project_id, chapter_no) 草稿。"""
def __init__(self) -> None:
self.drafts: dict[tuple[uuid.UUID, int], ChapterDraftView] = {}
self.accepted_versions: dict[tuple[uuid.UUID, int], int] = {}
self.accepted_content: dict[tuple[uuid.UUID, int, int], str] = {}
async def save_draft(
self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1
) -> ChapterDraftView:
view = ChapterDraftView(
project_id=project_id,
chapter_no=chapter_no,
volume=volume,
content=text,
status="draft",
version=1,
)
self.drafts[(project_id, chapter_no)] = view
return view
async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> ChapterDraftView | None:
return self.drafts.get((project_id, chapter_no))
async def max_version(self, project_id: uuid.UUID, chapter_no: int) -> int:
versions = [
v for (p, c), v in self.accepted_versions.items() if p == project_id and c == chapter_no
]
draft = 1 if (project_id, chapter_no) in self.drafts else 0
return max([*versions, draft], default=0)
async def promote_to_accepted(
self, project_id: uuid.UUID, chapter_no: int, *, content: str, volume: int = 1
) -> ChapterView:
next_version = (await self.max_version(project_id, chapter_no)) + 1
self.accepted_versions[(project_id, chapter_no)] = next_version
self.accepted_content[(project_id, chapter_no, next_version)] = content
return ChapterView(
project_id=project_id,
chapter_no=chapter_no,
volume=volume,
content=content,
status="accepted",
version=next_version,
)
async def latest_accepted(self, project_id: uuid.UUID, chapter_no: int) -> ChapterView | None:
ver = self.accepted_versions.get((project_id, chapter_no))
if ver is None:
return None
return ChapterView(
project_id=project_id,
chapter_no=chapter_no,
volume=1,
content=self.accepted_content[(project_id, chapter_no, ver)],
status="accepted",
version=ver,
)
class FakeReviewRepo:
"""实现 `ReviewRepo` Protocol内存留痕 + 历史 + 裁决;只 flush 语义无 DB"""
def __init__(self, *, fail_set_decisions: bool = False) -> None:
self.rows: list[ReviewView] = []
self._fail_set_decisions = fail_set_decisions
async def record(
self,
project_id: uuid.UUID,
chapter_no: int,
*,
chapter_version: int | None = None,
conflicts: list[dict[str, Any]],
foreshadow_sug: list[dict[str, Any]] | None = None,
style: dict[str, Any] | None = None,
pace: dict[str, Any] | None = None,
health_score: int | None = None,
) -> ReviewView:
view = ReviewView(
id=uuid.uuid4(),
project_id=project_id,
chapter_no=chapter_no,
chapter_version=chapter_version,
conflicts=[dict(c) for c in conflicts],
foreshadow_sug=[dict(s) for s in (foreshadow_sug or [])],
style=style,
pace=pace,
health_score=health_score,
)
# 新→旧:插到列表头部。
self.rows.insert(0, view)
return view
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[ReviewView]:
return [r for r in self.rows if r.project_id == project_id and r.chapter_no == chapter_no]
async def set_decisions(self, review_id: uuid.UUID, *, decisions: dict[str, Any]) -> ReviewView:
if self._fail_set_decisions:
raise RuntimeError("boom: set_decisions failed (transaction rollback test)")
for i, r in enumerate(self.rows):
if r.id == review_id:
updated = r.model_copy(update={"decisions": dict(decisions)})
self.rows[i] = updated
return updated
raise LookupError(f"review not found: {review_id}")
class FakeDigestAppendRepo:
"""实现 `DigestAppendRepo` Protocol内存 append-only"""
def __init__(self) -> None:
self.rows: list[tuple[uuid.UUID, int, dict[str, Any]]] = []
async def append(
self, project_id: uuid.UUID, chapter_no: int, *, facts: dict[str, Any]
) -> DigestView:
self.rows.append((project_id, chapter_no, dict(facts)))
return DigestView(chapter_no=chapter_no, facts=dict(facts))
class FakeSession:
"""最小 fake记录 commit 次数(验收事务边界断言)。"""
def __init__(self) -> None:
self.commits = 0
async def commit(self) -> None:
self.commits += 1
class FakeReviewGateway:
"""实现审稿/digest 节点最小依赖(`run`):吐固定结构化 `parsed`,绝不联网。"""
def __init__(self, parsed: BaseModel | None = None, error: Exception | None = None) -> None:
self._parsed = parsed
self._error = error
self.requests: list[LlmRequest] = []
async def run(self, req: LlmRequest) -> LlmResponse:
self.requests.append(req)
if self._error is not None:
raise self._error
return LlmResponse(
text=self._parsed.model_dump_json() if self._parsed else "{}",
parsed=self._parsed,
usage=Usage(
provider="fake",
model="fake",
input_tokens=1,
output_tokens=1,
cost_minor=0,
currency="USD",
),
served_by=ServedBy(provider="fake", model="fake"),
)
class FakeWriterGateway:
"""实现 write 节点最小依赖(`stream`):吐固定 `Delta` 序列,绝不联网。
可注入 `error` 以验证 SSE 归一层把异常归一为 error 事件。
"""
def __init__(self, chunks: list[str] | None = None, error: Exception | None = None) -> None:
self._chunks = chunks if chunks is not None else ["第一段。", "第二段。"]
self._error = error
self.requests: list[LlmRequest] = []
async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]:
self.requests.append(req)
for c in self._chunks:
yield Delta(text=c)
if self._error is not None:
raise self._error

View File

@@ -0,0 +1,56 @@
"""内存替身:凭据存储 + 提供商探测(端点测试用,无 DB/无网络)。
绝对导入 `from fakes import ...`(包目录无 __init__.py见 memory/gotchas
"""
from __future__ import annotations
import uuid
from ww_api.services.credentials import StoredCredential, StoredRouting
from ww_llm_gateway.adapters.base import Capabilities
class FakeCredentialStore:
"""实现 `CredentialStore` Protocol 的内存版。"""
def __init__(self) -> None:
# key: (owner_id, provider) → 密文
self.creds: dict[tuple[uuid.UUID, str], bytes] = {}
self.routing: dict[str, StoredRouting] = {}
async def list_credentials(self, owner_id: uuid.UUID) -> list[StoredCredential]:
return [
StoredCredential(provider=p, api_key_enc=blob)
for (o, p), blob in self.creds.items()
if o == owner_id
]
async def list_routing(self) -> list[StoredRouting]:
return list(self.routing.values())
async def get_credential(self, owner_id: uuid.UUID, provider: str) -> StoredCredential | None:
blob = self.creds.get((owner_id, provider))
if blob is None:
return None
return StoredCredential(provider=provider, api_key_enc=blob)
async def upsert_credential(
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
) -> None:
self.creds[(owner_id, provider)] = api_key_enc
async def upsert_routing(self, routing: StoredRouting) -> None:
self.routing[routing.tier] = routing
class FakeProviderProbe:
"""实现 `ProviderProbe` Protocol返回固定能力矩阵绝不联网。"""
def __init__(self, caps: Capabilities | None = None) -> None:
self.caps = caps or Capabilities(structured_output=True, prefix_cache=True, thinking=False)
self.calls: list[tuple[uuid.UUID, str]] = []
async def probe(self, owner_id: uuid.UUID, provider: str) -> Capabilities:
self.calls.append((owner_id, provider))
return self.caps

View File

@@ -0,0 +1,59 @@
"""T1.7 凭据加密工具单测——Fernet 往返 + 掩码ARCH §4.7)。
绝不在响应/日志回显明文 Key。
"""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from ww_api.security.credentials import (
CredentialKeyError,
decrypt_api_key,
encrypt_api_key,
mask_api_key,
)
KEY = Fernet.generate_key().decode()
def test_encrypt_decrypt_round_trip() -> None:
# Arrange
plaintext = "sk-abc123def456"
# Act
blob = encrypt_api_key(plaintext, key=KEY)
# Assert
assert isinstance(blob, bytes)
assert plaintext.encode() not in blob # 密文不含明文
assert decrypt_api_key(blob, key=KEY) == plaintext
def test_encrypt_is_non_deterministic() -> None:
# Fernet 含随机 IV——两次加密产物不同但都能解回
a = encrypt_api_key("sk-secret", key=KEY)
b = encrypt_api_key("sk-secret", key=KEY)
assert a != b
assert decrypt_api_key(a, key=KEY) == decrypt_api_key(b, key=KEY) == "sk-secret"
def test_missing_key_fails_fast() -> None:
with pytest.raises(CredentialKeyError):
encrypt_api_key("sk-x", key="")
def test_invalid_key_fails_fast() -> None:
with pytest.raises(CredentialKeyError):
encrypt_api_key("sk-x", key="not-a-valid-fernet-key")
def test_mask_shows_only_last_four() -> None:
assert mask_api_key("sk-abcdefgh1234") == "sk-…1234"
def test_mask_short_key_fully_hidden() -> None:
# 极短 key 不泄露任何字符
assert mask_api_key("ab") == "sk-…••••"
def test_mask_empty_key() -> None:
assert mask_api_key("") == "sk-…••••"

View File

@@ -0,0 +1,210 @@
"""T1.4 端点测试:立项 CRUD + 写章 SSE + 自动保存(内存替身,无 DB/无网络)。
DoD端点入 OpenAPIPOST draft 回 SSE 流mock 网关验证PUT draft 幂等。
"""
from __future__ import annotations
import uuid
import httpx
import pytest
from fakes_projects import FakeChapterRepo, FakeProjectRepo, FakeWriterGateway
from ww_core.domain.repositories import (
CharacterView,
DigestView,
ForeshadowView,
MemoryRepos,
OutlineView,
RuleView,
StyleView,
WorldEntityView,
)
from ww_shared import ErrorCode
class _EmptyOutlineRepo:
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return None
class _EmptyCharacterRepo:
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
return []
class _EmptyWorldEntityRepo:
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
return []
class _EmptyDigestRepo:
async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]:
return []
class _EmptyForeshadowRepo:
async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]:
return []
class _EmptyStyleRepo:
async def latest(self, project_id: uuid.UUID) -> StyleView | None:
return None
class _EmptyRulesRepo:
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
return []
def _empty_memory_repos() -> MemoryRepos:
return MemoryRepos(
outline=_EmptyOutlineRepo(),
character=_EmptyCharacterRepo(),
world_entity=_EmptyWorldEntityRepo(),
digest=_EmptyDigestRepo(),
foreshadow=_EmptyForeshadowRepo(),
style=_EmptyStyleRepo(),
rules=_EmptyRulesRepo(),
)
def _make_client(
*,
project_repo: FakeProjectRepo | None = None,
chapter_repo: FakeChapterRepo | None = None,
gateway: FakeWriterGateway | None = None,
) -> tuple[httpx.AsyncClient, FakeProjectRepo, FakeChapterRepo, FakeWriterGateway]:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_chapter_repo,
get_memory_repos,
get_project_repo,
get_writer_gateway,
)
project_repo = project_repo or FakeProjectRepo()
chapter_repo = chapter_repo or FakeChapterRepo()
gateway = gateway or FakeWriterGateway()
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
app.dependency_overrides[get_writer_gateway] = lambda: gateway
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, project_repo, chapter_repo, gateway
@pytest.mark.asyncio
async def test_create_project_returns_201() -> None:
client, _, _, _ = _make_client()
async with client:
resp = await client.post(
"/projects",
json={"title": "我的网文", "genre": "玄幻", "selling_points": ["爽点密集"]},
)
assert resp.status_code == 201
body = resp.json()
assert body["title"] == "我的网文"
assert body["genre"] == "玄幻"
assert body["selling_points"] == ["爽点密集"]
assert uuid.UUID(body["id"])
@pytest.mark.asyncio
async def test_list_projects() -> None:
client, repo, _, _ = _make_client()
async with client:
await client.post("/projects", json={"title": ""})
await client.post("/projects", json={"title": ""})
resp = await client.get("/projects")
assert resp.status_code == 200
titles = {p["title"] for p in resp.json()["projects"]}
assert titles == {"", ""}
@pytest.mark.asyncio
async def test_get_project_detail() -> None:
client, _, _, _ = _make_client()
async with client:
created = (await client.post("/projects", json={"title": "详情"})).json()
resp = await client.get(f"/projects/{created['id']}")
assert resp.status_code == 200
assert resp.json()["title"] == "详情"
@pytest.mark.asyncio
async def test_get_unknown_project_404() -> None:
client, _, _, _ = _make_client()
async with client:
resp = await client.get(f"/projects/{uuid.uuid4()}")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_create_project_rejects_blank_title() -> None:
client, _, _, _ = _make_client()
async with client:
resp = await client.post("/projects", json={"title": ""})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_draft_stream_yields_sse_tokens_and_done() -> 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 resp.headers["content-type"].startswith("text/event-stream")
text = resp.text
assert "event: token" in text
assert '"text": "阿福"' in text
assert "event: done" in text
# done 带累计长度(不回灌全文)
assert '"length": 6' in text
# 网关确实被调用,且只传 tier不传具体 model
assert len(gateway.requests) == 1
assert gateway.requests[0].tier == "writer"
@pytest.mark.asyncio
async def test_draft_stream_maps_error_to_sse_error_event() -> None:
from ww_shared import AppError
gateway = FakeWriterGateway(chunks=["半段"], error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
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
text = resp.text
assert "event: token" in text
assert "event: error" in text
assert ErrorCode.LLM_UNAVAILABLE in text
@pytest.mark.asyncio
async def test_put_draft_is_idempotent() -> None:
chapter_repo = FakeChapterRepo()
client, _, _, _ = _make_client(chapter_repo=chapter_repo)
pid = uuid.uuid4()
async with client:
r1 = await client.put(f"/projects/{pid}/chapters/3/draft", json={"text": "初稿"})
r2 = await client.put(f"/projects/{pid}/chapters/3/draft", json={"text": "改稿更长"})
assert r1.status_code == 200
assert r2.status_code == 200
# 同章节只一条草稿(版次不爆炸)
assert len(chapter_repo.drafts) == 1
saved = chapter_repo.drafts[(pid, 3)]
assert saved.content == "改稿更长"
assert saved.version == 1
assert r2.json()["length"] == len("改稿更长")

View File

@@ -0,0 +1,93 @@
"""T1.7 端点测试:凭据 upsert/列表/探测(内存替身,无 DB/无网络)。
DoD掩码往返、test-connection 经可注入探测回能力矩阵、明文绝不出响应。
"""
from __future__ import annotations
from fakes_providers import FakeCredentialStore, FakeProviderProbe
from fastapi.testclient import TestClient
from ww_api.security.credentials import decrypt_api_key
from ww_api.services.credentials import STUB_OWNER_ID
def test_get_empty_providers(client: TestClient) -> None:
resp = client.get("/settings/providers")
assert resp.status_code == 200
body = resp.json()
assert body == {"providers": [], "tier_routing": []}
def test_put_credential_encrypts_and_masks(
client: TestClient, store: FakeCredentialStore, enc_key: str
) -> None:
# Act
resp = client.put(
"/settings/providers",
json={"credentials": [{"provider": "deepseek", "api_key": "sk-secret-9999"}]},
)
# Assert: 响应含掩码,绝不含明文
assert resp.status_code == 200
body = resp.json()
assert "sk-secret-9999" not in resp.text
assert body["providers"][0]["provider"] == "deepseek"
# 存的是密文且能解回明文(往返)
blob = store.creds[(STUB_OWNER_ID, "deepseek")]
assert b"sk-secret-9999" not in blob
assert decrypt_api_key(blob, key=enc_key) == "sk-secret-9999"
def test_put_is_idempotent(client: TestClient, store: FakeCredentialStore) -> None:
payload = {"credentials": [{"provider": "deepseek", "api_key": "sk-a"}]}
client.put("/settings/providers", json=payload)
client.put(
"/settings/providers", json={"credentials": [{"provider": "deepseek", "api_key": "sk-b"}]}
)
# 同 (owner, provider) 只一条
assert len([k for k in store.creds if k[1] == "deepseek"]) == 1
def test_put_tier_routing(client: TestClient, store: FakeCredentialStore) -> None:
resp = client.put(
"/settings/providers",
json={
"tier_routing": [
{
"tier": "writer",
"provider": "deepseek",
"model": "deepseek-chat",
"fallback": ["kimi"],
}
]
},
)
assert resp.status_code == 200
routing = resp.json()["tier_routing"]
assert routing[0] == {
"tier": "writer",
"provider": "deepseek",
"model": "deepseek-chat",
"fallback": ["kimi"],
}
def test_test_connection_returns_capabilities(client: TestClient, probe: FakeProviderProbe) -> None:
resp = client.post("/settings/providers/test", json={"provider": "deepseek"})
assert resp.status_code == 200
body = resp.json()
assert body["provider"] == "deepseek"
assert body["ok"] is True
assert body["capabilities"] == {
"structured_output": True,
"prefix_cache": True,
"thinking": False,
}
assert probe.calls == [(STUB_OWNER_ID, "deepseek")]
def test_put_validation_rejects_blank_provider(client: TestClient) -> None:
resp = client.put(
"/settings/providers",
json={"credentials": [{"provider": "", "api_key": "sk-x"}]},
)
assert resp.status_code == 422

View File

@@ -0,0 +1,360 @@
"""项目(立项)+ 章节草稿端点C3 / ARCH §7.2, §7.3;不变量 #7
- POST /projects 立项向导 → projects 行owner_id=stub
- GET /projects 列出项目。
- GET /projects/:id 项目详情404 → NOT_FOUND
- POST /projects/:id/chapters/:no/draft 流式写章草稿SSEtext/event-stream
- PUT /projects/:id/chapters/:no/draft 自动保存草稿(幂等 upsert
不变量writer 只产草稿(不提升 accepted 版本/不抽 digest/不写状态——属 M2 验收);
agent 只传 tierDB 按 project_id/owner_id 过滤;日志脱敏(只记长度,不记正文)。
"""
from __future__ import annotations
import json
import uuid
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain.chapter_repo import ChapterRepo
from ww_core.domain.digest_repo import DigestAppendRepo
from ww_core.domain.project_repo import ProjectCreate, ProjectRepo
from ww_core.domain.repositories import MemoryRepos
from ww_core.domain.review_repo import ReviewRepo
from ww_core.memory import assemble
from ww_core.orchestrator import (
ChapterState,
SseEvent,
build_review_context,
build_review_graph,
normalize_deltas,
normalize_review,
stream_chapter_draft,
)
from ww_db import get_session
from ww_llm_gateway import Gateway
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.projects import (
AcceptRequest,
AcceptResponse,
DraftResponse,
DraftSaveRequest,
ProjectCreateRequest,
ProjectListResponse,
ProjectResponse,
ReviewHistoryItem,
ReviewHistoryResponse,
ReviewRequest,
)
from ww_api.services.accept_service import (
AcceptOutcome,
assert_conflicts_resolved,
run_accept_transaction,
)
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.digest_extraction import extract_digest_facts
from ww_api.services.project_deps import (
get_chapter_repo,
get_digest_append_repo,
get_digest_gateway,
get_memory_repos,
get_project_repo,
get_review_gateway,
get_review_repo,
get_writer_gateway,
)
log = get_logger("ww.api.projects")
router = APIRouter(prefix="/projects", tags=["projects"])
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
GatewayDep = Annotated[Gateway, Depends(get_writer_gateway)]
ReviewGatewayDep = Annotated[Gateway, Depends(get_review_gateway)]
DigestGatewayDep = Annotated[Gateway, Depends(get_digest_gateway)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
ReviewRepoDep = Annotated[ReviewRepo, Depends(get_review_repo)]
DigestRepoDep = Annotated[DigestAppendRepo, Depends(get_digest_append_repo)]
def _to_response(view: object) -> ProjectResponse:
# ProjectView 与 ProjectResponse 字段同名逐字段映射snake_case 契约)。
return ProjectResponse.model_validate(view, from_attributes=True)
@router.post("", status_code=201)
async def create_project(body: ProjectCreateRequest, repo: ProjectRepoDep) -> ProjectResponse:
view = await repo.create(
STUB_OWNER_ID,
ProjectCreate(
title=body.title,
genre=body.genre,
logline=body.logline,
premise=body.premise,
theme=body.theme,
selling_points=body.selling_points,
structure=body.structure,
),
)
log.info("project_created", project_id=str(view.id), title_len=len(view.title))
return _to_response(view)
@router.get("")
async def list_projects(repo: ProjectRepoDep) -> ProjectListResponse:
views = await repo.list_for_owner(STUB_OWNER_ID)
return ProjectListResponse(projects=[_to_response(v) for v in views])
@router.get("/{project_id}")
async def get_project(project_id: uuid.UUID, repo: ProjectRepoDep) -> ProjectResponse:
view = await repo.get(STUB_OWNER_ID, project_id)
if view is None:
raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found")
return _to_response(view)
def _encode_sse(event: SseEvent) -> str:
"""把归一事件编码为 text/event-stream 帧:`event: <name>\\ndata: <json>\\n\\n`。"""
payload = json.dumps(event.data, ensure_ascii=False)
return f"event: {event.event}\ndata: {payload}\n\n"
@router.post("/{project_id}/chapters/{chapter_no}/draft")
async def stream_draft(
project_id: uuid.UUID,
chapter_no: int,
request: Request,
repos: MemoryReposDep,
gateway: GatewayDep,
session: Annotated[AsyncSession, Depends(get_session)],
) -> StreamingResponse:
"""流式写章草稿:组装记忆 → 网关流 → 归一为 SSE 事件 → text/event-stream。"""
request_id = getattr(request.state, "request_id", None)
context = await assemble(repos, project_id, chapter_no)
log.info(
"draft_stream_start",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
stable_core_len=len(context.stable_core),
volatile_len=len(context.volatile),
)
deltas = stream_chapter_draft(
gateway,
stable_core=context.stable_core,
volatile=context.volatile,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
async def _frames() -> AsyncIterator[str]:
async for event in normalize_deltas(deltas, request_id=request_id):
yield _encode_sse(event)
# 网关在流末经 SqlAlchemyLedgerSink.record 把 usage_ledger 行 flush 进本请求 session
# sink 按设计不提交写库事务由编排层控制见不变量。draft 端点无其他写副作用,
# 故流耗尽后在此提交,确保「每次调用一条 usage_ledger」真正落库T1.9 暴露)。
await session.commit()
return StreamingResponse(
_frames(),
media_type="text/event-stream",
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
)
@router.put("/{project_id}/chapters/{chapter_no}/draft")
async def save_draft(
project_id: uuid.UUID,
chapter_no: int,
body: DraftSaveRequest,
repo: ChapterRepoDep,
) -> DraftResponse:
"""自动保存:幂等 upsert 草稿(同章节覆盖同一行,版次不爆炸)。"""
view = await repo.save_draft(project_id, chapter_no, text=body.text)
log.info(
"draft_saved",
project_id=str(project_id),
chapter_no=chapter_no,
length=len(view.content),
)
return DraftResponse(
project_id=view.project_id,
chapter_no=view.chapter_no,
volume=view.volume,
status=view.status,
version=view.version,
length=len(view.content),
)
async def _resolve_review_draft(
body: ReviewRequest,
chapter_repo: ChapterRepo,
project_id: uuid.UUID,
chapter_no: int,
) -> str:
"""取待审正文:请求体 `draft` 优先;否则回退到已保存草稿;都无 → NOT_FOUND。"""
if body.draft is not None and body.draft.strip():
return body.draft
saved = await chapter_repo.get_draft(project_id, chapter_no)
if saved is None or not saved.content.strip():
raise AppError(
ErrorCode.NOT_FOUND,
f"chapter {chapter_no} has no draft to review; provide `draft` in body",
)
return saved.content
@router.post("/{project_id}/chapters/{chapter_no}/review")
async def review_chapter(
project_id: uuid.UUID,
chapter_no: int,
body: ReviewRequest,
request: Request,
repos: MemoryReposDep,
chapter_repo: ChapterRepoDep,
review_repo: ReviewRepoDep,
gateway: ReviewGatewayDep,
session: Annotated[AsyncSession, Depends(get_session)],
) -> StreamingResponse:
"""续审SSE组审稿上下文 → 跑审稿子图 → 归一为 section/conflict/done 事件。
提交边界:网关 ledger + collect 经 review_repo.record 均只 flush端点在**流耗尽后**
`await session.commit()`(镜像 draft 端点,否则记账/留痕静默丢失)。
"""
request_id = getattr(request.state, "request_id", None)
draft = await _resolve_review_draft(body, chapter_repo, project_id, chapter_no)
context = await assemble(repos, project_id, chapter_no)
review_context = build_review_context(
draft=draft, stable_core=context.stable_core, volatile=context.volatile
)
log.info(
"review_stream_start",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
draft_len=len(draft),
review_context_len=len(review_context),
)
graph = build_review_graph(gateway, review_repo)
initial: ChapterState = {
"project_id": project_id,
"chapter_no": chapter_no,
"user_id": STUB_OWNER_ID,
"review_context": review_context,
}
async def _frames() -> AsyncIterator[str]:
final = await graph.ainvoke(initial)
reviews = final.get("reviews") or {}
async for event in normalize_review(reviews, request_id=request_id):
yield _encode_sse(event)
# collect 经 review_repo.record 落 chapter_reviews只 flush+ 网关 ledger 只 flush
# → 流耗尽后在此提交,确保审稿留痕 + usage_ledger 真正落库(同 draft 端点)。
await session.commit()
return StreamingResponse(
_frames(),
media_type="text/event-stream",
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
)
@router.get("/{project_id}/chapters/{chapter_no}/reviews")
async def list_reviews(
project_id: uuid.UUID,
chapter_no: int,
review_repo: ReviewRepoDep,
) -> ReviewHistoryResponse:
"""审稿历史(新→旧):供前端审稿页加载既往审稿留痕 + 裁决。"""
views = await review_repo.list_for_chapter(project_id, chapter_no)
items = [
ReviewHistoryItem(
id=v.id,
project_id=v.project_id,
chapter_no=v.chapter_no,
chapter_version=v.chapter_version,
conflicts=v.conflicts,
foreshadow_sug=v.foreshadow_sug,
style=v.style,
pace=v.pace,
health_score=v.health_score,
decisions=v.decisions,
)
for v in views
]
return ReviewHistoryResponse(reviews=items)
@router.post("/{project_id}/chapters/{chapter_no}/accept")
async def accept_chapter(
project_id: uuid.UUID,
chapter_no: int,
body: AcceptRequest,
request: Request,
chapter_repo: ChapterRepoDep,
digest_repo: DigestRepoDep,
review_repo: ReviewRepoDep,
gateway: DigestGatewayDep,
session: Annotated[AsyncSession, Depends(get_session)],
) -> AcceptResponse:
"""验收事务 + 冲突 gate§5.5gate事务前→ 终稿提炼 digest事务外R2
单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit。
"""
request_id = getattr(request.state, "request_id", None)
# R3审稿真相从领域表重读最近一条 chapter_reviews不依赖 checkpoint。
history = await review_repo.list_for_chapter(project_id, chapter_no)
latest_review = history[0] if history else None
# 冲突 gateR5事务前拦截不写库
assert_conflicts_resolved(latest_review, body.decisions)
log.info(
"accept_start",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
final_text_len=len(body.final_text),
decision_count=len(body.decisions),
has_review=latest_review is not None,
)
# R2终稿 digest 提炼在**事务外**做(别在持开事务里跨网络调 LLM
digest_facts = await extract_digest_facts(
gateway,
final_text=body.final_text,
user_id=STUB_OWNER_ID,
project_id=project_id,
chapter_no=chapter_no,
)
outcome: AcceptOutcome = await run_accept_transaction(
session=session,
chapter_repo=chapter_repo,
digest_repo=digest_repo,
review_repo=review_repo,
project_id=project_id,
chapter_no=chapter_no,
final_text=body.final_text,
digest_facts=digest_facts,
latest_review=latest_review,
decisions=body.decisions,
)
return AcceptResponse(
project_id=project_id,
chapter_no=chapter_no,
accepted_version=outcome.accepted_version,
digest_added=outcome.digest_added,
decisions_recorded=outcome.decisions_recorded,
review_id=outcome.review_id,
)

View File

@@ -0,0 +1,127 @@
"""提供商凭据与档位路由端点C3 / ARCH §4.7, §7.2UX §6.10)。
- GET /settings/providers 列出已配置提供商(掩码)+ 档位路由。
- PUT /settings/providers 幂等 upsert 凭据/档位路由,回掩码视图。
- POST /settings/providers/test 最小探测验 Key + 拉能力矩阵。
不变量:响应/日志绝不含明文 Key加密入库、仅掩码出站
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from ww_config import get_settings
from ww_llm_gateway.adapters.base import Capabilities
from ww_api.logging_config import get_logger
from ww_api.schemas.providers import (
CapabilitiesView,
ProvidersResponse,
ProvidersUpsertRequest,
ProviderView,
TestConnectionRequest,
TestConnectionResponse,
TierRoutingView,
)
from ww_api.security.credentials import encrypt_api_key, mask_api_key
from ww_api.services.credentials import (
STUB_OWNER_ID,
CredentialStore,
ProviderProbe,
StoredCredential,
StoredRouting,
)
from ww_api.services.provider_deps import (
get_credential_store,
get_provider_probe,
)
log = get_logger("ww.api.providers")
router = APIRouter(prefix="/settings/providers", tags=["settings"])
StoreDep = Annotated[CredentialStore, Depends(get_credential_store)]
ProbeDep = Annotated[ProviderProbe, Depends(get_provider_probe)]
def _mask_credential(cred: StoredCredential, plaintext: str | None) -> ProviderView:
# 存储层只有密文;掩码需明文末四位——若没有则全隐占位。
masked = mask_api_key(plaintext) if plaintext is not None else mask_api_key("")
return ProviderView(provider=cred.provider, masked_key=masked)
def _routing_view(r: StoredRouting) -> TierRoutingView:
return TierRoutingView(tier=r.tier, provider=r.provider, model=r.model, fallback=r.fallback)
async def _build_response(store: CredentialStore) -> ProvidersResponse:
creds = await store.list_credentials(STUB_OWNER_ID)
routing = await store.list_routing()
# 列表视图无明文,掩码占位(不解密历史密文以免无谓 IO/泄露面)。
providers = [ProviderView(provider=c.provider, masked_key=mask_api_key("")) for c in creds]
return ProvidersResponse(
providers=providers,
tier_routing=[_routing_view(r) for r in routing],
)
@router.get("")
async def list_providers(store: StoreDep) -> ProvidersResponse:
return await _build_response(store)
@router.put("")
async def upsert_providers(
body: ProvidersUpsertRequest,
store: StoreDep,
) -> ProvidersResponse:
enc_key = get_settings().credential_enc_key
for cred in body.credentials:
api_key_enc = encrypt_api_key(cred.api_key, key=enc_key)
await store.upsert_credential(STUB_OWNER_ID, cred.provider, api_key_enc)
# 仅记 provider + 末四位掩码,绝不记明文
log.info(
"provider_credential_upserted",
provider=cred.provider,
masked_key=mask_api_key(cred.api_key),
)
for routing in body.tier_routing:
await store.upsert_routing(
StoredRouting(
tier=routing.tier,
provider=routing.provider,
model=routing.model,
fallback=routing.fallback,
)
)
log.info(
"tier_routing_upserted",
tier=routing.tier,
provider=routing.provider,
model=routing.model,
)
return await _build_response(store)
def _capabilities_view(caps: Capabilities) -> CapabilitiesView:
return CapabilitiesView(
structured_output=caps.structured_output,
prefix_cache=caps.prefix_cache,
thinking=caps.thinking,
)
@router.post("/test")
async def test_connection(
body: TestConnectionRequest,
probe: ProbeDep,
) -> TestConnectionResponse:
caps = await probe.probe(STUB_OWNER_ID, body.provider)
log.info("provider_probe_ok", provider=body.provider)
return TestConnectionResponse(
provider=body.provider,
ok=True,
capabilities=_capabilities_view(caps),
)

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

View File

@@ -0,0 +1,53 @@
"""提供商 API Key 的对称加解密与掩码ARCH §4.7)。
- 加密用 `cryptography` 的 Fernet密钥取 `settings.credential_enc_key`。
- 缺失/非法密钥 → 立即失败(`CredentialKeyError`),绝不静默。
- `mask_api_key` 产出可安全展示/落库回显的掩码;明文永不出响应、永不进日志。
"""
from __future__ import annotations
from cryptography.fernet import Fernet, InvalidToken
# 掩码常量:前缀 + 省略号 + 末四位。短 key 用 bullet 占位,绝不泄露字符。
_MASK_PREFIX = "sk-…"
_MASK_PLACEHOLDER = "••••"
_LAST_N = 4
class CredentialKeyError(RuntimeError):
"""加密密钥缺失或非法——快速失败,调用方负责映射为 5xx/配置错误。"""
def _fernet(key: str) -> Fernet:
if not key:
raise CredentialKeyError(
"credential_enc_key 未配置;无法加解密提供商凭据。请设置环境变量。"
)
try:
return Fernet(key.encode())
except (ValueError, TypeError) as exc:
# 不回显 key 内容
raise CredentialKeyError(
"credential_enc_key 非法(需为 32 字节 url-safe base64 Fernet key"
) from exc
def encrypt_api_key(plaintext: str, *, key: str) -> bytes:
"""加密明文 API Key返回可入库的密文字节`api_key_enc`)。"""
return _fernet(key).encrypt(plaintext.encode())
def decrypt_api_key(blob: bytes, *, key: str) -> str:
"""解密密文字节回明文。仅供探测/调用使用,绝不进响应或日志。"""
try:
return _fernet(key).decrypt(blob).decode()
except InvalidToken as exc:
raise CredentialKeyError("凭据密文无法解密(密钥不匹配或数据损坏)。") from exc
def mask_api_key(plaintext: str) -> str:
"""掩码:仅露末四位,如 `sk-…1234`;过短/空则全隐。"""
if len(plaintext) < _LAST_N:
return f"{_MASK_PREFIX}{_MASK_PLACEHOLDER}"
return f"{_MASK_PREFIX}{plaintext[-_LAST_N:]}"

View File

@@ -0,0 +1,149 @@
"""凭据存储与提供商探测的接口 + SQLAlchemy/网关实现ARCH §4.7)。
路由依赖这里的 **接口**Protocol测试注入内存替身运行时用 SQLAlchemy/网关实现。
单用户原型:`owner_id` 用固定 stub见 `STUB_OWNER_ID`),多租户化时改为按认证主体取。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Protocol
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import ProviderCredential, TierRouting
from ww_llm_gateway.adapters.base import Capabilities
# 单用户 stub owner与网关 Scope.user_id 的 stub 约定一致UUID(int=1))。
# 多租户化时此常量由认证主体替换(见 ARCH §4.7 隔离)。
STUB_OWNER_ID = uuid.UUID(int=1)
@dataclass(frozen=True)
class StoredCredential:
"""存储层视图:含密文,绝不出 API 边界(路由仅取 provider 并掩码)。"""
provider: str
api_key_enc: bytes
@dataclass(frozen=True)
class StoredRouting:
tier: str
provider: str
model: str
fallback: list[str]
class CredentialStore(Protocol):
"""凭据 + 档位路由的读写接口(按 owner_id 隔离)。"""
async def list_credentials(self, owner_id: uuid.UUID) -> list[StoredCredential]: ...
async def list_routing(self) -> list[StoredRouting]: ...
async def get_credential(
self, owner_id: uuid.UUID, provider: str
) -> StoredCredential | None: ...
async def upsert_credential(
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
) -> None: ...
async def upsert_routing(self, routing: StoredRouting) -> None: ...
class ProviderProbe(Protocol):
"""最小连通探测:验证 Key + 返回能力矩阵。测试注入假探测,绝不联网。"""
async def probe(self, owner_id: uuid.UUID, provider: str) -> Capabilities: ...
class SqlCredentialStore:
"""SQLAlchemy 实现:写 `provider_credentials` / `tier_routing`,幂等 upsert。"""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_credentials(self, owner_id: uuid.UUID) -> list[StoredCredential]:
rows = (
await self._session.execute(
select(ProviderCredential).where(ProviderCredential.owner_id == owner_id)
)
).scalars()
return [StoredCredential(provider=r.provider, api_key_enc=r.api_key_enc) for r in rows]
async def list_routing(self) -> list[StoredRouting]:
rows = (await self._session.execute(select(TierRouting))).scalars()
return [
StoredRouting(
tier=r.tier, provider=r.provider, model=r.model, fallback=list(r.fallback)
)
for r in rows
]
async def get_credential(self, owner_id: uuid.UUID, provider: str) -> StoredCredential | None:
row = (
await self._session.execute(
select(ProviderCredential).where(
ProviderCredential.owner_id == owner_id,
ProviderCredential.provider == provider,
)
)
).scalar_one_or_none()
if row is None:
return None
return StoredCredential(provider=row.provider, api_key_enc=row.api_key_enc)
async def upsert_credential(
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
) -> None:
# 显式 read-modify-write唯一约束含可空 project_idPG ON CONFLICT
# 在 NULL 上不去重NULLS DISTINCT故不用 on_conflict。
existing = (
await self._session.execute(
select(ProviderCredential).where(
ProviderCredential.owner_id == owner_id,
ProviderCredential.project_id.is_(None),
ProviderCredential.provider == provider,
)
)
).scalar_one_or_none()
if existing is None:
self._session.add(
ProviderCredential(
owner_id=owner_id,
project_id=None,
provider=provider,
api_key_enc=api_key_enc,
)
)
else:
existing.api_key_enc = api_key_enc
await self._session.commit()
async def upsert_routing(self, routing: StoredRouting) -> None:
existing = (
await self._session.execute(
select(TierRouting).where(
TierRouting.project_id.is_(None),
TierRouting.tier == routing.tier,
)
)
).scalar_one_or_none()
if existing is None:
self._session.add(
TierRouting(
project_id=None,
tier=routing.tier,
provider=routing.provider,
model=routing.model,
fallback=routing.fallback,
)
)
else:
existing.provider = routing.provider
existing.model = routing.model
existing.fallback = routing.fallback
await self._session.commit()

View File

@@ -0,0 +1,162 @@
"""项目/章节端点的依赖装配(运行时实现)。
- `get_project_repo` / `get_chapter_repo`:把请求 session 装配成 SQLAlchemy repo。
- `get_writer_gateway`:据 writer 档位路由从已存凭据解密 → 建 OpenAI 兼容适配器
→ `Gateway`(注入 `SqlAlchemyLedgerSink` + `resolve_route`)。这是 **draft SSE 的可注入缝**——
测试经 `app.dependency_overrides[get_writer_gateway]` 注入 mock 网关(产 `Delta`,绝不联网)。
- `seed_stub_user`:幂等 seed 单用户 stubowner_id FK 依赖它,见 memory/gotchas
"""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends
from openai import AsyncOpenAI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo
from ww_core.domain.repositories import MemoryRepos
from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo
from ww_core.memory.sql_repositories import sql_memory_repos
from ww_db import get_session
from ww_db.models import User
from ww_llm_gateway import (
Gateway,
OpenAICompatAdapter,
SqlAlchemyLedgerSink,
resolve_route,
)
from ww_llm_gateway.types import Tier
from ww_shared import AppError, ErrorCode
from ww_api.security.credentials import (
CredentialKeyError,
decrypt_api_key,
)
from ww_api.services.credentials import (
STUB_OWNER_ID,
CredentialStore,
SqlCredentialStore,
)
from ww_api.services.provider_deps import _PROVIDER_BASE_URLS
# 单用户 stub 的占位邮箱(多租户化时由真实主体替换)。
_STUB_USER_EMAIL = "stub@local"
async def seed_stub_user(session: AsyncSession) -> None:
"""幂等 seed 单用户 stub 行——所有 owner_id FKprojects/usage_ledger/...)依赖它。
无该行时插入;已存在则跳过。在 app lifespan 启动时调用一次。
"""
existing = (
await session.execute(select(User).where(User.id == STUB_OWNER_ID))
).scalar_one_or_none()
if existing is not None:
return
session.add(User(id=STUB_OWNER_ID, email=_STUB_USER_EMAIL, display_name="stub"))
await session.commit()
def get_project_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> ProjectRepo:
return SqlProjectRepo(session)
def get_chapter_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> ChapterRepo:
return SqlChapterRepo(session)
def get_memory_repos(
session: Annotated[AsyncSession, Depends(get_session)],
) -> MemoryRepos:
"""记忆组装的 7-repo 捆绑draft SSE 用)。测试覆盖此依赖注入内存 fake。"""
return sql_memory_repos(session)
def get_review_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> ReviewRepo:
"""审稿留痕 reporeview SSE collect / 历史 / accept 裁决)。"""
return SqlReviewRepo(session)
def get_digest_append_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> DigestAppendRepo:
"""章节摘要写侧 repo验收事务追加终稿 digest"""
return SqlDigestAppendRepo(session)
async def build_gateway_for_tier(
session: AsyncSession, store: CredentialStore, tier: Tier
) -> Gateway:
"""据指定档位路由解密对应 provider 凭据 → 建网关(解析器仍为全局 `resolve_route`)。
无凭据/未知 provider → `LLM_UNAVAILABLE`(友好提示,前端引导去配置)。
解析器用 `resolve_route`(按 tier 路由);这里只决定**要预备哪个 provider 的适配器**。
"""
settings = get_settings()
route = resolve_route(tier)
base_url = _PROVIDER_BASE_URLS.get(route.provider)
if base_url is None:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"{tier} 档位 provider {route.provider} 暂不支持",
{"provider": route.provider, "tier": tier},
)
cred = await store.get_credential(STUB_OWNER_ID, route.provider)
if cred is None:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"{tier} 档位 provider {route.provider} 未配置凭据,请先在设置中配置",
{"provider": route.provider, "tier": tier},
)
try:
api_key = decrypt_api_key(cred.api_key_enc, key=settings.credential_enc_key)
except CredentialKeyError as exc:
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
adapter = OpenAICompatAdapter(provider=route.provider, client=client)
return Gateway(
adapters={route.provider: adapter},
ledger=SqlAlchemyLedgerSink(session),
resolver=resolve_route,
)
async def build_writer_gateway(session: AsyncSession, store: CredentialStore) -> Gateway:
"""据 writer 档位路由解密对应 provider 凭据 → 建网关。"""
return await build_gateway_for_tier(session, store, "writer")
async def get_writer_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""draft SSE 的可注入网关缝。测试覆盖此依赖注入 mock产 `Delta`,绝不联网)。"""
store = SqlCredentialStore(session)
return await build_writer_gateway(session, store)
async def get_review_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""续审analyst 档位)的可注入网关缝。测试经 override 注 mock产 `parsed`,绝不联网)。"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
async def get_digest_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""验收终稿 digest 提炼light 档位)的可注入网关缝。测试经 override 注 mock。"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "light")

View File

@@ -0,0 +1,93 @@
"""提供商凭据端点的依赖装配(运行时实现)。
把 `get_session` 装配成 `SqlCredentialStore`;把网关适配器装配成探测器。
测试经 `app.dependency_overrides` 注入内存替身——不联网。
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import Depends
from openai import AsyncOpenAI
from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_db import get_session
from ww_llm_gateway.adapters.base import Capabilities
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
from ww_shared import AppError, ErrorCode
from ww_api.security.credentials import (
CredentialKeyError,
decrypt_api_key,
)
from ww_api.services.credentials import (
CredentialStore,
SqlCredentialStore,
)
# 已知 OpenAI 兼容提供商 → base_urlARCH §4.2)。
_PROVIDER_BASE_URLS: dict[str, str] = {
"deepseek": "https://api.deepseek.com",
"kimi": "https://api.moonshot.cn/v1",
"qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"glm": "https://open.bigmodel.cn/api/paas/v4",
"openai": "https://api.openai.com/v1",
}
def get_credential_store(
session: Annotated[AsyncSession, Depends(get_session)],
) -> CredentialStore:
return SqlCredentialStore(session)
class GatewayProviderProbe:
"""运行时探测:解密 Key→建 OpenAI 兼容适配器→最小请求验 Key→回能力矩阵。
依赖 store 取密文 + settings 取加密 key。绝不在日志/响应回显明文。
"""
def __init__(self, store: CredentialStore, enc_key: str) -> None:
self._store = store
self._enc_key = enc_key
async def probe(self, owner_id: uuid.UUID, provider: str) -> Capabilities:
cred = await self._store.get_credential(owner_id, provider)
if cred is None:
raise AppError(
ErrorCode.NOT_FOUND,
f"provider {provider} 未配置凭据",
{"provider": provider},
)
base_url = _PROVIDER_BASE_URLS.get(provider)
if base_url is None:
raise AppError(
ErrorCode.VALIDATION,
f"未知提供商 {provider}",
{"provider": provider},
)
try:
api_key = decrypt_api_key(cred.api_key_enc, key=self._enc_key)
except CredentialKeyError as exc:
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
adapter = OpenAICompatAdapter(provider=provider, client=client)
try:
# 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。
await client.models.list()
except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"provider {provider} 连接探测失败",
{"provider": provider},
) from exc
return adapter.capabilities()
def get_provider_probe(
store: Annotated[CredentialStore, Depends(get_credential_store)],
) -> GatewayProviderProbe:
return GatewayProviderProbe(store, get_settings().credential_enc_key)

80
apps/web/app/page.tsx Normal file
View File

@@ -0,0 +1,80 @@
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { ProjectCard } from "@/components/ProjectCard";
import { fetchProjects } from "@/lib/api/server";
import type { ProjectResponse } from "@/lib/api/types";
// 作品库Dashboard 入口UX §6.1。Server Component 读取。
export default async function DashboardPage() {
let projects: ProjectResponse[] = [];
let loadError = false;
try {
const data = await fetchProjects();
projects = data.projects ?? [];
} catch {
loadError = true;
}
return (
<AppShell>
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-8 flex items-center justify-between">
<h1 className="font-serif text-3xl text-ink"></h1>
<Link
href="/projects/new"
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
>
</Link>
</div>
{loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
API
</p>
) : projects.length === 0 ? (
<EmptyState />
) : (
<ul className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => (
<li key={p.id}>
<ProjectCard project={p} />
</li>
))}
<li>
<NewProjectCard />
</li>
</ul>
)}
</div>
</AppShell>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center gap-4 rounded border border-line bg-panel py-20 text-center">
<p className="font-serif text-2xl text-ink"></p>
<p className="text-ink-soft"></p>
<Link
href="/projects/new"
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel hover:opacity-90"
>
</Link>
</div>
);
}
function NewProjectCard() {
return (
<Link
href="/projects/new"
className="flex h-full min-h-[160px] flex-col items-center justify-center rounded border border-dashed border-line bg-panel text-center hover:border-cinnabar"
>
<span className="font-serif text-xl text-cinnabar"> </span>
<span className="mt-2 text-sm text-ink-soft"></span>
</Link>
);
}

View File

@@ -0,0 +1,19 @@
import { notFound } from "next/navigation";
import { Workbench } from "@/components/workbench/Workbench";
import { fetchProject } from "@/lib/api/server";
interface PageProps {
params: Promise<{ id: string }>;
}
// 写作工作台页UX §6.3。Server Component 取项目Workbench 负责编辑/流式/保存。
export default async function WritePage({ params }: PageProps) {
const { id } = await params;
try {
const project = await fetchProject(id);
return <Workbench project={project} />;
} catch {
notFound();
}
}

View File

@@ -0,0 +1,12 @@
import { AppShell } from "@/components/AppShell";
import { ProjectWizard } from "@/components/ProjectWizard";
export default function NewProjectPage() {
return (
<AppShell>
<div className="px-8 py-10">
<ProjectWizard />
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,29 @@
import { AppShell } from "@/components/AppShell";
import { ProvidersSettings } from "@/components/settings/ProvidersSettings";
import { fetchProviders } from "@/lib/api/server";
import type { ProvidersResponse } from "@/lib/api/types";
// 模型与提供商设置UX §6.10。Server Component 取初始数据。
export default async function ProvidersSettingsPage() {
let initial: ProvidersResponse = { providers: [], tier_routing: [] };
let loadError = false;
try {
initial = await fetchProviders();
} catch {
loadError = true;
}
return (
<AppShell title="设置 模型与提供商">
<div className="mx-auto max-w-3xl px-8 py-10">
{loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
API
</p>
) : (
<ProvidersSettings initial={initial} />
)}
</div>
</AppShell>
);
}

View File

@@ -0,0 +1,26 @@
import Link from "next/link";
import type { ProjectResponse } from "@/lib/api/types";
interface ProjectCardProps {
project: ProjectResponse;
}
// 作品卡UX §6.1):书名衬线大字、题材、一句话故事。
// M1 无字数/章数/待办统计端点 → 暂不展示该徽标(避免编造 API
export function ProjectCard({ project }: ProjectCardProps) {
return (
<Link
href={`/projects/${project.id}/write`}
className="flex h-full min-h-[160px] flex-col rounded border border-line bg-panel p-6 shadow-paper hover:border-cinnabar"
>
<h2 className="font-serif text-2xl text-ink">{project.title}</h2>
{project.genre ? (
<p className="mt-2 text-sm text-ink-soft">{project.genre}</p>
) : null}
{project.logline ? (
<p className="mt-3 line-clamp-3 text-sm text-ink">{project.logline}</p>
) : null}
</Link>
);
}

View File

@@ -0,0 +1,301 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import {
GENRES,
SELLING_POINT_PRESETS,
STRUCTURES,
WIZARD_STEPS,
canAdvance,
canSubmit,
clampStep,
emptyWizardForm,
toCreateRequest,
type WizardForm,
} from "@/lib/wizard/wizard";
const STEP_TITLES = [
"书名 + 题材",
"一句话故事 + 卖点 + 结构",
"立意 / 总纲",
"主角 / 金手指",
"基础世界观",
];
// 立项向导UX §6.2。Client Component收集字段 → POST /projects → 进工作台。
export function ProjectWizard() {
const router = useRouter();
const toast = useToast();
const [step, setStep] = useState(1);
const [form, setForm] = useState<WizardForm>(emptyWizardForm);
const [submitting, setSubmitting] = useState(false);
const update = (patch: Partial<WizardForm>): void =>
setForm((prev) => ({ ...prev, ...patch }));
const toggleSellingPoint = (point: string): void =>
setForm((prev) => ({
...prev,
sellingPoints: prev.sellingPoints.includes(point)
? prev.sellingPoints.filter((p) => p !== point)
: [...prev.sellingPoints, point],
}));
const goBack = (): void => setStep((s) => clampStep(s - 1));
const goNext = (): void => setStep((s) => clampStep(s + 1));
const submit = async (): Promise<void> => {
if (!canSubmit(form)) {
toast("请先填写书名", "error");
setStep(1);
return;
}
setSubmitting(true);
const { data, error } = await api.POST("/projects", {
body: toCreateRequest(form),
});
if (error || !data) {
setSubmitting(false);
toast("创建作品失败,请重试", "error");
return;
}
router.push(`/projects/${data.id}/write`);
};
const isLast = step === WIZARD_STEPS;
return (
<div className="mx-auto max-w-2xl rounded border border-line bg-panel p-8 shadow-paper">
<div className="mb-6 flex items-center justify-between">
<h1 className="font-serif text-2xl text-ink"></h1>
<StepDots current={step} />
</div>
<p className="mb-4 text-sm text-ink-soft">
{step}/{WIZARD_STEPS} · {STEP_TITLES[step - 1]}
</p>
<div className="min-h-[220px]">
{step === 1 && <StepBasics form={form} update={update} />}
{step === 2 && (
<StepStory
form={form}
update={update}
toggleSellingPoint={toggleSellingPoint}
/>
)}
{step === 3 && <StepPremise form={form} update={update} />}
{step === 4 && <StepProtagonist form={form} update={update} />}
{step === 5 && <StepWorld />}
</div>
<div className="mt-8 flex items-center justify-between">
<button
type="button"
onClick={goBack}
disabled={step === 1}
className="rounded border border-line px-4 py-2 text-sm text-ink disabled:opacity-40"
>
</button>
{isLast ? (
<button
type="button"
onClick={submit}
disabled={submitting || !canSubmit(form)}
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40"
>
{submitting ? "创建中…" : "完成立项 →"}
</button>
) : (
<button
type="button"
onClick={goNext}
disabled={!canAdvance(step, form)}
className="rounded bg-cinnabar px-5 py-2 text-sm text-panel disabled:opacity-40"
>
</button>
)}
</div>
</div>
);
}
function StepDots({ current }: { current: number }) {
return (
<div className="flex gap-1.5" aria-hidden="true">
{Array.from({ length: WIZARD_STEPS }, (_, i) => (
<span
key={i}
className={`h-2 w-2 rounded-full ${
i + 1 <= current ? "bg-cinnabar" : "bg-line"
}`}
/>
))}
</div>
);
}
interface StepProps {
form: WizardForm;
update: (patch: Partial<WizardForm>) => void;
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="mb-4 block">
<span className="mb-1 block text-sm text-ink-soft">{label}</span>
{children}
</label>
);
}
const inputCls =
"w-full rounded border border-line bg-bg px-3 py-2 text-ink focus:border-cinnabar focus:outline-none";
function StepBasics({ form, update }: StepProps) {
return (
<div>
<Field label="书名(必填)">
<input
className={inputCls}
value={form.title}
onChange={(e) => update({ title: e.target.value })}
placeholder="例:逐光而行"
autoFocus
/>
</Field>
<Field label="题材">
<select
className={inputCls}
value={form.genre}
onChange={(e) => update({ genre: e.target.value })}
>
<option value=""></option>
{GENRES.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</Field>
</div>
);
}
function StepStory({
form,
update,
toggleSellingPoint,
}: StepProps & { toggleSellingPoint: (p: string) => void }) {
return (
<div>
<Field label="一句话故事logline">
<textarea
className={`${inputCls} h-20 resize-none`}
value={form.logline}
onChange={(e) => update({ logline: e.target.value })}
placeholder="废柴少年觉醒禁忌血脉,在仙门倾轧中逆势封神。"
/>
</Field>
<Field label="故事结构">
<div className="flex gap-2">
{STRUCTURES.map((s) => (
<button
type="button"
key={s}
onClick={() => update({ structure: s })}
className={`rounded border px-3 py-1.5 text-sm ${
form.structure === s
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "border-line text-ink"
}`}
>
{s}
</button>
))}
</div>
</Field>
<fieldset>
<legend className="mb-1 block text-sm text-ink-soft"></legend>
<div className="flex flex-wrap gap-2">
{SELLING_POINT_PRESETS.map((p) => (
<button
type="button"
key={p}
aria-pressed={form.sellingPoints.includes(p)}
onClick={() => toggleSellingPoint(p)}
className={`rounded border px-3 py-1.5 text-sm ${
form.sellingPoints.includes(p)
? "border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "border-line text-ink"
}`}
>
{p}
</button>
))}
</div>
</fieldset>
</div>
);
}
function StepPremise({ form, update }: StepProps) {
return (
<div>
<Field label="立意premise">
<textarea
className={`${inputCls} h-20 resize-none`}
value={form.premise}
onChange={(e) => update({ premise: e.target.value })}
placeholder="故事的核心命题与前提。"
/>
</Field>
<Field label="主题theme">
<input
className={inputCls}
value={form.theme}
onChange={(e) => update({ theme: e.target.value })}
placeholder="例:抗争与代价"
/>
</Field>
</div>
);
}
function StepProtagonist({ form, update }: StepProps) {
// M1 projects 表无独立主角/金手指字段;先并入立意/总纲文本,避免编造 API。
return (
<div>
<p className="mb-3 text-sm text-ink-soft">
M1
</p>
<Field label="主角 / 金手指概要">
<textarea
className={`${inputCls} h-28 resize-none`}
value={form.premise}
onChange={(e) => update({ premise: e.target.value })}
placeholder="主角设定、金手指来源与限制……"
/>
</Field>
</div>
);
}
function StepWorld() {
return (
<div className="rounded border border-dashed border-line bg-bg p-6 text-sm text-ink-soft">
</div>
);
}

View File

@@ -0,0 +1,209 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import { KNOWN_PROVIDERS, TIER_LABELS } from "@/lib/settings/providers";
import type {
CapabilitiesView,
ProvidersResponse,
TierRoutingView,
} from "@/lib/api/types";
interface ProvidersSettingsProps {
initial: ProvidersResponse;
}
interface TestResult {
ok: boolean;
capabilities: CapabilitiesView;
}
// 设置页主体UX §6.10):档位路由(只读展示)+ 凭据行(脱敏 / 添加更新 / 测试连接)。
export function ProvidersSettings({ initial }: ProvidersSettingsProps) {
const toast = useToast();
const [providers, setProviders] = useState(initial.providers ?? []);
const tierRouting: TierRoutingView[] = initial.tier_routing ?? [];
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [savingId, setSavingId] = useState<string | null>(null);
const [testingId, setTestingId] = useState<string | null>(null);
const [results, setResults] = useState<Record<string, TestResult>>({});
const maskedFor = (id: string): string | null =>
providers.find((p) => p.provider === id)?.masked_key ?? null;
const saveCredential = async (providerId: string): Promise<void> => {
const apiKey = (drafts[providerId] ?? "").trim();
if (!apiKey) {
toast("请先输入 API Key", "error");
return;
}
setSavingId(providerId);
const { data, error } = await api.PUT("/settings/providers", {
body: { credentials: [{ provider: providerId, api_key: apiKey }] },
});
setSavingId(null);
if (error || !data) {
toast("保存凭据失败,请重试", "error");
return;
}
setProviders(data.providers ?? []);
setDrafts((prev) => ({ ...prev, [providerId]: "" }));
toast("凭据已保存", "success");
};
const testConnection = async (providerId: string): Promise<void> => {
setTestingId(providerId);
const { data, error } = await api.POST("/settings/providers/test", {
body: { provider: providerId },
});
setTestingId(null);
if (error || !data) {
toast("测试连接失败", "error");
return;
}
setResults((prev) => ({
...prev,
[providerId]: { ok: data.ok, capabilities: data.capabilities },
}));
toast(data.ok ? "连接成功" : "连接未通过", data.ok ? "success" : "error");
};
return (
<div>
<section className="mb-10">
<h2 className="mb-3 font-serif text-xl text-ink"></h2>
{tierRouting.length === 0 ? (
<p className="rounded border border-dashed border-line bg-panel p-4 text-sm text-ink-soft">
使
</p>
) : (
<ul className="divide-y divide-line rounded border border-line bg-panel">
{tierRouting.map((t) => (
<li
key={t.tier}
className="flex items-center gap-4 px-4 py-3 text-sm"
>
<span className="w-20 text-ink">
{TIER_LABELS[t.tier] ?? t.tier}
</span>
<span className="font-mono text-ink-soft">
{t.provider} · {t.model}
</span>
{t.fallback && t.fallback.length > 0 ? (
<span className="ml-auto font-mono text-xs text-ink-soft">
退 {t.fallback.join(", ")}
</span>
) : null}
</li>
))}
</ul>
)}
</section>
<section>
<h2 className="mb-3 font-serif text-xl text-ink"></h2>
{providers.length === 0 ? (
<p className="mb-4 rounded border border-dashed border-line bg-panel p-4 text-sm text-ink-soft">
Anthropic /
DeepSeek
</p>
) : null}
<ul className="divide-y divide-line rounded border border-line bg-panel">
{KNOWN_PROVIDERS.map((prov) => {
const masked = maskedFor(prov.id);
const result = results[prov.id];
return (
<li key={prov.id} className="px-4 py-4">
<div className="flex flex-wrap items-center gap-3">
<span
className={`h-2 w-2 rounded-full ${masked ? "bg-pass" : "bg-line"}`}
aria-hidden="true"
/>
<span className="w-28 text-sm text-ink">{prov.label}</span>
{masked ? (
<span className="font-mono text-xs text-ink-soft">
{masked}
</span>
) : null}
<label className="sr-only" htmlFor={`key-${prov.id}`}>
{prov.label} API Key
</label>
<input
id={`key-${prov.id}`}
type="password"
autoComplete="off"
value={drafts[prov.id] ?? ""}
onChange={(e) =>
setDrafts((prev) => ({
...prev,
[prov.id]: e.target.value,
}))
}
placeholder={masked ? "输入新 Key 以更新" : "输入 API Key"}
className="min-w-[12rem] flex-1 rounded border border-line bg-bg px-3 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
/>
<button
type="button"
onClick={() => saveCredential(prov.id)}
disabled={savingId === prov.id}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel disabled:opacity-40"
>
{savingId === prov.id
? "保存中…"
: masked
? "更新凭据"
: "添加凭据"}
</button>
<button
type="button"
onClick={() => testConnection(prov.id)}
disabled={testingId === prov.id}
className="rounded border border-line px-3 py-1.5 text-sm text-ink disabled:opacity-40"
>
{testingId === prov.id ? "测试中…" : "测试连接"}
</button>
</div>
{result ? (
<div className="mt-2 flex items-center gap-2 pl-5">
<span
className={`text-xs ${result.ok ? "text-pass" : "text-conflict"}`}
>
{result.ok ? "✓ 已连接" : "✗ 未连接"}
</span>
<CapabilityBadges caps={result.capabilities} />
</div>
) : null}
</li>
);
})}
</ul>
</section>
</div>
);
}
function CapabilityBadges({ caps }: { caps: CapabilitiesView }) {
const badges: { on: boolean; label: string }[] = [
{ on: caps.structured_output, label: "结构化" },
{ on: caps.prefix_cache, label: "前缀缓存" },
{ on: caps.thinking, label: "思考" },
];
return (
<div className="flex gap-1.5">
{badges.map((b) => (
<span
key={b.label}
className={`rounded px-2 py-0.5 text-[11px] ${
b.on
? "bg-[var(--color-cinnabar-wash)] text-cinnabar"
: "bg-bg text-ink-soft/50"
}`}
>
{b.label}
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,27 @@
// 右栏「本章助手」UX §6.3)。
// M1 无端点暴露注入选择轨迹/四审结果 → 占位说明,不编造 API。
export function ChapterAssistant() {
return (
<aside className="hidden border-l border-line bg-panel p-4 lg:block">
<h2 className="text-sm font-semibold text-ink"></h2>
<section className="mt-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h3>
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
M1 /
</p>
</section>
<section className="mt-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h3>
<p className="mt-2 rounded border border-dashed border-line bg-bg p-3 text-xs text-ink-soft">
M2 / / /
</p>
</section>
</aside>
);
}

View File

@@ -0,0 +1,24 @@
interface ChapterListProps {
currentChapterNo: number;
}
// 左栏目录UX §6.3。M1 无章节列表端点 → 仅展示当前章占位。
export function ChapterList({ currentChapterNo }: ChapterListProps) {
return (
<aside className="hidden border-r border-line bg-panel py-4 lg:block">
<h2 className="px-4 text-xs font-semibold uppercase tracking-wide text-ink-soft">
</h2>
<ul className="mt-2">
<li>
<span className="flex items-center gap-2 border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] px-4 py-2 text-sm text-cinnabar">
<span aria-hidden="true"></span> {currentChapterNo}
</span>
</li>
</ul>
<p className="mt-4 px-4 text-xs text-ink-soft/70">
</p>
</aside>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
interface EditorProps {
value: string;
onChange: (value: string) => void;
streaming: boolean;
}
// 中栏正文编辑器宋体、720px 居中、行高 1.9UX §2.3 / §6.3)。
// 流式中显示朱砂光标提示prefers-reduced-motion 下不闪烁,见 globals.css
export function Editor({ value, onChange, streaming }: EditorProps) {
return (
<div className="mx-auto max-w-prose">
<label htmlFor="chapter-editor" className="sr-only">
</label>
<textarea
id="chapter-editor"
value={value}
onChange={(e) => onChange(e.target.value)}
readOnly={streaming}
aria-busy={streaming}
placeholder="夜色如墨……点击「写本章」让 AI 起草,或直接在此书写。"
className="min-h-[60vh] w-full resize-none bg-transparent font-serif text-[18px] leading-[1.9] text-ink placeholder:text-ink-soft/50 focus:outline-none"
/>
{streaming ? (
<span
className="typewriter-cursor ml-0.5 inline-block h-[1.2em] w-[2px] translate-y-1 bg-cinnabar align-middle"
aria-hidden="true"
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,156 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import type { ProjectResponse } from "@/lib/api/types";
import { useAutosave } from "@/lib/autosave/useAutosave";
import { useDraftStream } from "@/lib/stream/useDraftStream";
import { ChapterList } from "./ChapterList";
import { ChapterAssistant } from "./ChapterAssistant";
import { Editor } from "./Editor";
interface WorkbenchProps {
project: ProjectResponse;
}
// M1单章工作台。章节列表为占位无章节列表端点默认第 1 章。
const M1_CHAPTER_NO = 1;
export function Workbench({ project }: WorkbenchProps) {
const chapterNo = M1_CHAPTER_NO;
const [text, setText] = useState("");
const autosave = useAutosave(project.id, chapterNo);
const stream = useDraftStream();
const lastStreamText = useRef("");
// 流式 token 累积进编辑器(打字机);停止/结束后已生成部分留在草稿。
useEffect(() => {
const streamed = stream.state.text;
if (
(stream.state.phase === "streaming" ||
stream.state.phase === "done" ||
stream.state.phase === "aborted") &&
streamed !== lastStreamText.current
) {
lastStreamText.current = streamed;
setText(streamed);
autosave.onChange(streamed);
}
}, [stream.state.phase, stream.state.text, autosave]);
const onWrite = (): void => {
void stream.start(project.id, chapterNo);
};
const onEditorChange = (value: string): void => {
setText(value);
autosave.onChange(value);
};
const wordCount = text.length;
return (
<AppShell
title={`${project.title}`}
subtitle={`${chapterNo}`}
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]">
<ChapterList currentChapterNo={chapterNo} />
<section className="flex min-w-0 flex-col bg-bg">
<div className="flex-1 overflow-auto px-6 py-8">
<Editor
value={text}
onChange={onEditorChange}
streaming={stream.isStreaming}
/>
</div>
<Toolbar
projectId={project.id}
chapterNo={chapterNo}
wordCount={wordCount}
savedLabel={autosave.savedLabel}
saveStatus={autosave.status}
streaming={stream.isStreaming}
streamError={stream.state.error}
onWrite={onWrite}
onStop={stream.stop}
/>
</section>
<ChapterAssistant />
</div>
</AppShell>
);
}
interface ToolbarProps {
projectId: string;
chapterNo: number;
wordCount: number;
savedLabel: string | null;
saveStatus: ReturnType<typeof useAutosave>["status"];
streaming: boolean;
streamError: { code: string; message: string } | null;
onWrite: () => void;
onStop: () => void;
}
function Toolbar({
projectId,
chapterNo,
wordCount,
savedLabel,
saveStatus,
streaming,
streamError,
onWrite,
onStop,
}: ToolbarProps) {
return (
<div className="border-t border-line bg-panel px-6 py-3">
{streamError ? (
<p className="mb-2 text-sm text-conflict">
{streamError.code}{streamError.message}
</p>
) : null}
<div className="flex items-center gap-4">
{streaming ? (
<button
type="button"
onClick={onStop}
className="rounded border border-conflict px-4 py-2 text-sm text-conflict"
>
</button>
) : (
<button
type="button"
onClick={onWrite}
className="rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90"
>
</button>
)}
<span className="font-mono text-xs text-ink-soft">
{wordCount.toLocaleString()}
</span>
<Link
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
className="rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar hover:bg-[var(--color-cinnabar-wash)]"
>
稿
</Link>
<span className="ml-auto font-mono text-xs text-ink-soft">
{saveStatus === "saving"
? "保存中…"
: saveStatus === "error"
? "保存失败"
: (savedLabel ?? "尚未保存")}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDebouncedSaver, formatSavedAt } from "./autosave";
describe("createDebouncedSaver", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("saves only once after the debounce window for rapid edits", () => {
const save = vi.fn().mockResolvedValue(undefined);
const saver = createDebouncedSaver(save, 1000);
saver.schedule("a");
saver.schedule("ab");
saver.schedule("abc");
expect(save).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith("abc");
});
it("flush triggers the pending save immediately", () => {
const save = vi.fn().mockResolvedValue(undefined);
const saver = createDebouncedSaver(save, 1000);
saver.schedule("hello");
saver.flush();
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith("hello");
// No double-fire when the timer would have elapsed.
vi.advanceTimersByTime(1000);
expect(save).toHaveBeenCalledTimes(1);
});
it("cancel prevents a scheduled save", () => {
const save = vi.fn().mockResolvedValue(undefined);
const saver = createDebouncedSaver(save, 1000);
saver.schedule("x");
saver.cancel();
vi.advanceTimersByTime(2000);
expect(save).not.toHaveBeenCalled();
});
it("each new schedule resets the timer (only last value persists)", () => {
const save = vi.fn().mockResolvedValue(undefined);
const saver = createDebouncedSaver(save, 1000);
saver.schedule("one");
vi.advanceTimersByTime(800);
saver.schedule("two");
vi.advanceTimersByTime(800);
expect(save).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith("two");
});
});
describe("formatSavedAt", () => {
it("formats as 保存于 hh:mm zero-padded", () => {
const d = new Date(2026, 5, 18, 9, 4);
expect(formatSavedAt(d)).toBe("保存于 09:04");
});
});

View File

@@ -0,0 +1,52 @@
// 自动保存的纯逻辑:防抖调度器 + 时间格式化。与 React 解耦,便于单测。
export const AUTOSAVE_DELAY_MS = 1200;
export type SaveFn = (text: string) => Promise<void>;
export interface DebouncedSaver {
schedule: (text: string) => void;
flush: () => void;
cancel: () => void;
}
// 防抖:最后一次输入静默 delayMs 后才真正保存。flush 立即触发待保存项。
export function createDebouncedSaver(
save: SaveFn,
delayMs: number = AUTOSAVE_DELAY_MS,
): DebouncedSaver {
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: string | null = null;
const run = (): void => {
if (pending === null) return;
const text = pending;
pending = null;
timer = null;
void save(text);
};
return {
schedule(text: string): void {
pending = text;
if (timer !== null) clearTimeout(timer);
timer = setTimeout(run, delayMs);
},
flush(): void {
if (timer !== null) clearTimeout(timer);
run();
},
cancel(): void {
if (timer !== null) clearTimeout(timer);
timer = null;
pending = null;
},
};
}
// 「保存于 hh:mm」标签。
export function formatSavedAt(date: Date): string {
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
return `保存于 ${hh}:${mm}`;
}

View File

@@ -0,0 +1,74 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { useToast } from "@/components/Toast";
import {
AUTOSAVE_DELAY_MS,
createDebouncedSaver,
formatSavedAt,
type DebouncedSaver,
} from "./autosave";
export type SaveStatus = "idle" | "saving" | "saved" | "error";
export interface UseAutosave {
status: SaveStatus;
savedLabel: string | null;
onChange: (text: string) => void;
flush: () => void;
}
// 防抖自动保存草稿:乐观置 saving → PUT 成功置 saved落「保存于 hh:mm」
// 失败回滚状态 + toast不丢正文已在编辑器里
export function useAutosave(
projectId: string,
chapterNo: number,
): UseAutosave {
const [status, setStatus] = useState<SaveStatus>("idle");
const [savedLabel, setSavedLabel] = useState<string | null>(null);
const toast = useToast();
const lastSavedRef = useRef<string | null>(null);
const save = useCallback(
async (text: string): Promise<void> => {
if (text === lastSavedRef.current) return;
setStatus("saving");
const { error } = await api.PUT(
"/projects/{project_id}/chapters/{chapter_no}/draft",
{
params: { path: { project_id: projectId, chapter_no: chapterNo } },
body: { text },
},
);
if (error) {
setStatus("error");
toast("自动保存失败,稍后重试(正文未丢失)", "error");
return;
}
lastSavedRef.current = text;
setSavedLabel(formatSavedAt(new Date()));
setStatus("saved");
},
[projectId, chapterNo, toast],
);
const saver: DebouncedSaver = useMemo(
() => createDebouncedSaver(save, AUTOSAVE_DELAY_MS),
[save],
);
useEffect(() => () => saver.cancel(), [saver]);
const onChange = useCallback(
(text: string) => {
saver.schedule(text);
},
[saver],
);
const flush = useCallback(() => saver.flush(), [saver]);
return { status, savedLabel, onChange, flush };
}

View File

@@ -0,0 +1,16 @@
// 已知提供商UX §6.10)。后端按 provider 字符串识别;这里只是 UI 候选列表。
export const KNOWN_PROVIDERS: { id: string; label: string }[] = [
{ id: "anthropic", label: "Anthropic" },
{ id: "deepseek", label: "DeepSeek" },
{ id: "kimi", label: "Kimi" },
{ id: "openai", label: "OpenAI" },
{ id: "qwen", label: "通义千问" },
{ id: "glm", label: "智谱 GLM" },
{ id: "gemini", label: "Gemini" },
];
export const TIER_LABELS: Record<string, string> = {
writer: "写手档",
analyst: "分析档",
light: "轻量档",
};

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
SseFrameBuffer,
initialStreamState,
parseSseBlock,
reduceStream,
type SseEvent,
} from "./sse";
describe("parseSseBlock", () => {
it("parses a token frame", () => {
const evt = parseSseBlock('event:token\ndata:{"text":"夜"}');
expect(evt).toEqual({ event: "token", data: { text: "夜" } });
});
it("parses a done frame", () => {
const evt = parseSseBlock('event:done\ndata:{"length":12}');
expect(evt).toEqual({ event: "done", data: { length: 12 } });
});
it("parses an error frame with request_id", () => {
const evt = parseSseBlock(
'event:error\ndata:{"code":"INTERNAL","message":"boom","request_id":"r1"}',
);
expect(evt).toEqual({
event: "error",
data: { code: "INTERNAL", message: "boom", request_id: "r1" },
});
});
it("ignores comment/heartbeat lines and tolerates leading space after colon", () => {
const evt = parseSseBlock(': keep-alive\nevent: token\ndata: {"text":"x"}');
expect(evt).toEqual({ event: "token", data: { text: "x" } });
});
it("returns null for unknown event types", () => {
expect(parseSseBlock('event:section\ndata:{"x":1}')).toBeNull();
});
it("returns null for malformed json", () => {
expect(parseSseBlock("event:token\ndata:{not json")).toBeNull();
});
});
describe("SseFrameBuffer", () => {
it("emits complete blocks split by blank line and buffers the tail", () => {
const buf = new SseFrameBuffer();
const first = buf.push('event:token\ndata:{"text":"a"}\n\nevent:tok');
expect(first).toEqual([{ event: "token", data: { text: "a" } }]);
const second = buf.push('en\ndata:{"text":"b"}\n\n');
expect(second).toEqual([{ event: "token", data: { text: "b" } }]);
});
it("handles CRLF boundaries", () => {
const buf = new SseFrameBuffer();
const out = buf.push('event:token\r\ndata:{"text":"z"}\r\n\r\n');
expect(out).toEqual([{ event: "token", data: { text: "z" } }]);
});
});
describe("reduceStream", () => {
it("accumulates token text and marks streaming", () => {
const tokens: SseEvent[] = [
{ event: "token", data: { text: "夜色" } },
{ event: "token", data: { text: "如墨" } },
];
const state = tokens.reduce(reduceStream, initialStreamState);
expect(state.text).toBe("夜色如墨");
expect(state.phase).toBe("streaming");
});
it("marks done on done event preserving text", () => {
let s = reduceStream(initialStreamState, {
event: "token",
data: { text: "abc" },
});
s = reduceStream(s, { event: "done", data: { length: 3 } });
expect(s.phase).toBe("done");
expect(s.text).toBe("abc");
});
it("captures error and retains partial text", () => {
let s = reduceStream(initialStreamState, {
event: "token",
data: { text: "partial" },
});
s = reduceStream(s, {
event: "error",
data: { code: "LLM_UNAVAILABLE", message: "no key" },
});
expect(s.phase).toBe("error");
expect(s.text).toBe("partial");
expect(s.error?.code).toBe("LLM_UNAVAILABLE");
});
});

106
apps/web/lib/stream/sse.ts Normal file
View File

@@ -0,0 +1,106 @@
// SSE 帧解析 + 草稿流归一(对齐 C3 / ARCH §7.3)。
// 帧格式:`event:<token|done|error>\ndata:<json>\n\n`。
// 纯逻辑,便于单测(不依赖浏览器/DOM
export interface TokenEvent {
event: "token";
data: { text: string };
}
export interface DoneEvent {
event: "done";
data: { length: number };
}
export interface ErrorEvent {
event: "error";
data: { code: string; message: string; request_id?: string | null };
}
export type SseEvent = TokenEvent | DoneEvent | ErrorEvent;
const KNOWN_EVENTS = new Set(["token", "done", "error"]);
// 把一个完整 SSE 块(多行)解析成事件;无法解析则返回 null跳过
export function parseSseBlock(block: string): SseEvent | null {
let event = "";
const dataLines: string[] = [];
for (const rawLine of block.split("\n")) {
const line = rawLine.replace(/\r$/, "");
if (line.startsWith(":")) continue; // 注释/心跳
const sep = line.indexOf(":");
if (sep === -1) continue;
const field = line.slice(0, sep);
const value = line.slice(sep + 1).replace(/^ /, "");
if (field === "event") event = value;
else if (field === "data") dataLines.push(value);
}
if (!KNOWN_EVENTS.has(event) || dataLines.length === 0) return null;
let data: unknown;
try {
data = JSON.parse(dataLines.join("\n"));
} catch {
return null;
}
return { event, data } as SseEvent;
}
// 增量缓冲:吃进一段文本,吐出已完成的事件块(以空行分隔),保留未完成尾部。
export class SseFrameBuffer {
private buf = "";
push(chunk: string): SseEvent[] {
this.buf += chunk;
const events: SseEvent[] = [];
let idx: number;
// 块之间以空行(\n\n兼容 \r\n\r\n分隔。
while ((idx = this.findBoundary(this.buf)) !== -1) {
const block = this.buf.slice(0, idx.valueOf());
this.buf = this.buf.slice(this.boundaryEnd(this.buf, idx));
const evt = parseSseBlock(block);
if (evt) events.push(evt);
}
return events;
}
private findBoundary(s: string): number {
const a = s.indexOf("\n\n");
const b = s.indexOf("\r\n\r\n");
if (a === -1) return b;
if (b === -1) return a;
return Math.min(a, b);
}
private boundaryEnd(s: string, idx: number): number {
return s.startsWith("\r\n\r\n", idx) ? idx + 4 : idx + 2;
}
}
export type StreamPhase = "idle" | "streaming" | "done" | "error" | "aborted";
export interface StreamState {
phase: StreamPhase;
text: string;
error: { code: string; message: string; request_id?: string | null } | null;
}
export const initialStreamState: StreamState = {
phase: "idle",
text: "",
error: null,
};
// 纯 reducer把单个事件折叠进状态打字机文本累积
export function reduceStream(state: StreamState, event: SseEvent): StreamState {
switch (event.event) {
case "token":
return {
...state,
phase: "streaming",
text: state.text + event.data.text,
};
case "done":
return { ...state, phase: "done" };
case "error":
return { ...state, phase: "error", error: event.data };
default:
return state;
}
}

View File

@@ -0,0 +1,122 @@
"use client";
import { useCallback, useReducer, useRef } from "react";
import { API_BASE_PUBLIC } from "@/lib/api/config";
import {
SseFrameBuffer,
initialStreamState,
reduceStream,
type StreamState,
} from "./sse";
type Action =
| { type: "start" }
| { type: "events"; events: ReturnType<SseFrameBuffer["push"]> }
| { type: "abort" }
| { type: "fail"; code: string; message: string }
| { type: "reset"; text: string };
function reducer(state: StreamState, action: Action): StreamState {
switch (action.type) {
case "start":
return { phase: "streaming", text: "", error: null };
case "events":
return action.events.reduce(reduceStream, state);
case "abort":
return { ...state, phase: "aborted" };
case "fail":
return {
...state,
phase: "error",
error: { code: action.code, message: action.message },
};
case "reset":
return { ...initialStreamState, text: action.text };
default:
return state;
}
}
export interface UseDraftStream {
state: StreamState;
isStreaming: boolean;
start: (projectId: string, chapterNo: number) => Promise<void>;
stop: () => void;
reset: (text: string) => void;
}
// 消费 POST .../draft 的 SSE 流。用 fetch+ReadableStreamEventSource 不支持 POST
// "停" = abort 连接(已生成部分保留在 state.text由调用方落入草稿
export function useDraftStream(): UseDraftStream {
const [state, dispatch] = useReducer(reducer, initialStreamState);
const controllerRef = useRef<AbortController | null>(null);
const stop = useCallback(() => {
controllerRef.current?.abort();
controllerRef.current = null;
dispatch({ type: "abort" });
}, []);
const reset = useCallback((text: string) => {
dispatch({ type: "reset", text });
}, []);
const start = useCallback(async (projectId: string, chapterNo: number) => {
const controller = new AbortController();
controllerRef.current = controller;
dispatch({ type: "start" });
try {
const res = await fetch(
`${API_BASE_PUBLIC}/projects/${projectId}/chapters/${chapterNo}/draft`,
{
method: "POST",
headers: { Accept: "text/event-stream" },
signal: controller.signal,
},
);
if (!res.ok || !res.body) {
// 流前错误(如无凭据 → 503 LLM_UNAVAILABLEJSON 信封而非帧)。
let code = "STREAM_FAILED";
let message = `写章请求失败(${res.status}`;
try {
const body = (await res.json()) as {
error?: { code?: string; message?: string };
};
if (body.error?.code) code = body.error.code;
if (body.error?.message) message = body.error.message;
} catch {
// 非 JSON 信封,沿用默认文案。
}
dispatch({ type: "fail", code, message });
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
const buffer = new SseFrameBuffer();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const events = buffer.push(decoder.decode(value, { stream: true }));
if (events.length > 0) dispatch({ type: "events", events });
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
// 用户主动停止state 已置 aborted。
return;
}
const message = err instanceof Error ? err.message : "未知网络错误";
dispatch({ type: "fail", code: "NETWORK", message });
} finally {
controllerRef.current = null;
}
}, []);
return {
state,
isStreaming: state.phase === "streaming",
start,
stop,
reset,
};
}

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
canAdvance,
canSubmit,
clampStep,
emptyWizardForm,
toCreateRequest,
WIZARD_STEPS,
type WizardForm,
} from "./wizard";
describe("wizard step gating", () => {
it("blocks advancing past step 1 without a title", () => {
expect(canAdvance(1, emptyWizardForm)).toBe(false);
});
it("allows advancing step 1 once title is set", () => {
expect(canAdvance(1, { ...emptyWizardForm, title: "逐光而行" })).toBe(true);
});
it("allows advancing later steps even when fields are empty", () => {
expect(canAdvance(3, emptyWizardForm)).toBe(true);
});
it("clamps step within bounds", () => {
expect(clampStep(0)).toBe(1);
expect(clampStep(WIZARD_STEPS + 3)).toBe(WIZARD_STEPS);
expect(clampStep(3)).toBe(3);
});
it("requires a title to submit", () => {
expect(canSubmit(emptyWizardForm)).toBe(false);
expect(canSubmit({ ...emptyWizardForm, title: "x" })).toBe(true);
});
});
describe("toCreateRequest", () => {
it("maps form to snake_case request, nulling empty optionals", () => {
const form: WizardForm = {
title: " 逐光而行 ",
genre: "玄幻",
logline: "",
sellingPoints: ["逆袭", "系统流"],
structure: "三幕",
premise: " ",
theme: "抗争",
};
expect(toCreateRequest(form)).toEqual({
title: "逐光而行",
genre: "玄幻",
logline: null,
premise: null,
theme: "抗争",
selling_points: ["逆袭", "系统流"],
structure: "三幕",
});
});
});
// wizard→create 流程:归一后的请求体经 mock 客户端提交,返回新建项目 id。
describe("wizard create flow (mocked client)", () => {
it("submits the normalized body and resolves a project id", async () => {
const post = async (
_path: string,
opts: { body: ReturnType<typeof toCreateRequest> },
): Promise<{ data: { id: string }; error: null }> => {
expect(opts.body.title).toBe("青冥录");
expect(opts.body.selling_points).toEqual(["群像"]);
return { data: { id: "proj-123" }, error: null };
};
const form: WizardForm = {
...emptyWizardForm,
title: "青冥录",
sellingPoints: ["群像"],
};
const { data } = await post("/projects", { body: toCreateRequest(form) });
expect(data.id).toBe("proj-123");
});
});

View File

@@ -0,0 +1,62 @@
import type { ProjectCreateRequest } from "@/lib/api/types";
// 立项向导 5 步UX §6.2)。表单状态用字符串/数组,提交时归一为 ProjectCreateRequest。
export interface WizardForm {
title: string;
genre: string;
logline: string;
sellingPoints: string[];
structure: string;
premise: string;
theme: string;
}
export const WIZARD_STEPS = 5;
export const emptyWizardForm: WizardForm = {
title: "",
genre: "",
logline: "",
sellingPoints: [],
structure: "",
premise: "",
theme: "",
};
export const GENRES = ["玄幻", "仙侠", "都市", "科幻", "历史", "悬疑"];
export const STRUCTURES = ["三幕", "故事圈", "雪花"];
export const SELLING_POINT_PRESETS = ["逆袭", "打脸", "系统流", "双男主", "群像"];
// 每一步可否前进:仅第 1 步(书名)必填,其余可空着继续(草稿式立项)。
export function canAdvance(step: number, form: WizardForm): boolean {
if (step === 1) return form.title.trim().length > 0;
return true;
}
// 整个向导可否提交:书名必填。
export function canSubmit(form: WizardForm): boolean {
return form.title.trim().length > 0;
}
export function clampStep(step: number): number {
if (step < 1) return 1;
if (step > WIZARD_STEPS) return WIZARD_STEPS;
return step;
}
// 归一为后端请求体snake_case空值落 null/省略)。
export function toCreateRequest(form: WizardForm): ProjectCreateRequest {
const trim = (s: string): string | null => {
const v = s.trim();
return v.length > 0 ? v : null;
};
return {
title: form.title.trim(),
genre: trim(form.genre),
logline: trim(form.logline),
premise: trim(form.premise),
theme: trim(form.theme),
selling_points: form.sellingPoints,
structure: trim(form.structure),
};
}

2
packages/core/README.md Normal file
View File

@@ -0,0 +1,2 @@
# core (@llm/@backend) — 占位骨架
领域核心(domain/状态机)、编排器(orchestrator/LangGraph 图)、记忆服务(memory)。Phase 1+ 由对应 owner 填充。

View File

@@ -0,0 +1,26 @@
[project]
name = "ww-core"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = [
"langgraph>=0.2.40",
"langgraph-checkpoint-postgres>=2.0",
"pydantic>=2.7",
"ww-shared",
"ww-config",
"ww-db",
"ww-llm-gateway",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["ww_core"]
[tool.uv.sources]
ww-shared = { workspace = true }
ww-config = { workspace = true }
ww-db = { workspace = true }
ww-llm-gateway = { workspace = true }

View File

@@ -0,0 +1,122 @@
"""编排器测试替身——mock 网关(`stream` / `run`+ 内存审稿留痕 repo。
绝对导入(无包目录下不能相对导入,见 gotchas 2026-06-18`from fakes_orchestrator import ...`。
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from typing import Any
from pydantic import BaseModel
from ww_llm_gateway.types import (
Delta,
LlmRequest,
LlmResponse,
ServedBy,
Usage,
)
class FakeStreamGateway:
"""按预设 token 列表吐 `Delta`,并记录收到的 `LlmRequest`(断言请求构造)。"""
def __init__(self, tokens: list[str]) -> None:
self._tokens = tokens
self.last_request: LlmRequest | None = None
self.call_count = 0
async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]:
self.last_request = req
self.call_count += 1
for tok in self._tokens:
yield Delta(text=tok)
class RaisingGateway:
"""先吐几个 token再抛指定异常——模拟流式中途失败验 SSE error 归一)。"""
def __init__(self, tokens: list[str], exc: Exception) -> None:
self._tokens = tokens
self._exc = exc
async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]:
for tok in self._tokens:
yield Delta(text=tok)
raise self._exc
def _stub_usage() -> Usage:
return Usage(
provider="fake",
model="fake-analyst",
input_tokens=1,
output_tokens=1,
cost_minor=0,
currency="USD",
)
class FakeRunGateway:
"""按预设 `parsed` 实例回 `LlmResponse`,记录收到的请求(断言审稿请求构造)。"""
def __init__(self, parsed: BaseModel) -> None:
self._parsed = parsed
self.last_request: LlmRequest | None = None
self.call_count = 0
async def run(self, req: LlmRequest) -> LlmResponse:
self.last_request = req
self.call_count += 1
return LlmResponse(
text=self._parsed.model_dump_json(),
parsed=self._parsed,
usage=_stub_usage(),
served_by=ServedBy(provider="fake", model="fake-analyst"),
)
class FailingRunGateway:
"""`run` 抛指定异常——验「某审失败不阻塞其余」+ 未完成占位归一。"""
def __init__(self, exc: Exception) -> None:
self._exc = exc
self.call_count = 0
async def run(self, req: LlmRequest) -> LlmResponse:
self.call_count += 1
raise self._exc
class FakeReviewRepo:
"""内存审稿留痕 repo实现 `ReviewRepo` Protocol 的 `record`)。"""
def __init__(self) -> None:
self.records: list[dict[str, Any]] = []
async def record(
self,
project_id: uuid.UUID,
chapter_no: int,
*,
chapter_version: int | None = None,
conflicts: list[dict[str, Any]],
foreshadow_sug: list[dict[str, Any]] | None = None,
style: dict[str, Any] | None = None,
pace: dict[str, Any] | None = None,
health_score: int | None = None,
) -> Any:
entry = {
"id": uuid.uuid4(),
"project_id": project_id,
"chapter_no": chapter_no,
"chapter_version": chapter_version,
"conflicts": list(conflicts),
"foreshadow_sug": list(foreshadow_sug or []),
"style": style,
"pace": pace,
"health_score": health_score,
}
self.records.append(entry)
return entry

View File

@@ -0,0 +1,270 @@
"""T1.2 记忆服务单测——确定性选择 + 渲染 + 组装ARCH §3.4/§5.3)。
全部用内存 fake repo无 DB断言确定性、稳定块排序、无时间戳/UUID。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from ww_core.domain.repositories import (
CharacterView,
DigestView,
ForeshadowView,
MemoryRepos,
OutlineView,
RuleView,
StyleView,
WorldEntityView,
)
from ww_core.memory import (
AssembledContext,
EntityKind,
SelectedEntity,
SelectionTrace,
assemble,
render_cards,
select_relevant_entities,
)
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
# ---- 内存 fake repositories ----
@dataclass
class FakeOutlineRepo:
rows: dict[int, OutlineView] = field(default_factory=dict)
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return self.rows.get(chapter_no)
@dataclass
class FakeCharacterRepo:
rows: list[CharacterView] = field(default_factory=list)
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
return list(self.rows)
@dataclass
class FakeWorldEntityRepo:
rows: list[WorldEntityView] = field(default_factory=list)
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
return list(self.rows)
@dataclass
class FakeDigestRepo:
rows: list[DigestView] = field(default_factory=list)
async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]:
return sorted(self.rows, key=lambda d: d.chapter_no, reverse=True)[:k]
@dataclass
class FakeForeshadowRepo:
rows: list[ForeshadowView] = field(default_factory=list)
async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]:
wanted = set(codes)
return [r for r in self.rows if r.code in wanted]
@dataclass
class FakeStyleRepo:
row: StyleView | None = None
async def latest(self, project_id: uuid.UUID) -> StyleView | None:
return self.row
@dataclass
class FakeRulesRepo:
rows: list[RuleView] = field(default_factory=list)
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
return list(self.rows)
def build_repos(
*,
outline: dict[int, OutlineView] | None = None,
characters: list[CharacterView] | None = None,
world: list[WorldEntityView] | None = None,
digests: list[DigestView] | None = None,
foreshadows: list[ForeshadowView] | None = None,
style: StyleView | None = None,
rules: list[RuleView] | None = None,
) -> MemoryRepos:
return MemoryRepos(
outline=FakeOutlineRepo(outline or {}),
character=FakeCharacterRepo(characters or []),
world_entity=FakeWorldEntityRepo(world or []),
digest=FakeDigestRepo(digests or []),
foreshadow=FakeForeshadowRepo(foreshadows or []),
style=FakeStyleRepo(style),
rules=FakeRulesRepo(rules or []),
)
# ---- select_relevant_entities ----
def char(name: str, role: str | None = None, latest_state: str | None = None) -> CharacterView:
return CharacterView(name=name, role=role, latest_state=latest_state)
def world(name: str, type_: str = "设定", latest_state: str | None = None) -> WorldEntityView:
return WorldEntityView(type=type_, name=name, latest_state=latest_state)
def test_selects_main_character_even_when_not_in_beats() -> None:
outline = OutlineView(volume=1, chapter_no=1, beats={"summary": "无人提及"})
trace = select_relevant_entities(
outline=outline,
characters=[char("林动", role="主角"), char("路人甲", role="配角")],
world_entities=[],
recent_digests=[],
)
names = {e.name for e in trace.selected}
assert "林动" in names
assert "路人甲" not in names
main = next(e for e in trace.selected if e.name == "林动")
assert "main_character" in main.reasons
def test_selects_entity_named_in_beats() -> None:
outline = OutlineView(volume=1, chapter_no=2, beats={"entities": ["青檀"], "text": "青檀出场"})
trace = select_relevant_entities(
outline=outline,
characters=[char("青檀", role="配角")],
world_entities=[],
recent_digests=[],
)
assert any(e.name == "青檀" and "explicit_beat" in e.reasons for e in trace.selected)
def test_selects_recent_digest_entity() -> None:
outline = OutlineView(volume=1, chapter_no=10, beats={})
digests = [DigestView(chapter_no=9, facts={"entities": ["玄机阁"]})]
trace = select_relevant_entities(
outline=outline,
characters=[],
world_entities=[world("玄机阁")],
recent_digests=digests,
)
assert any(e.name == "玄机阁" and "recent_digest" in e.reasons for e in trace.selected)
def test_union_dedups_reasons_sorted() -> None:
outline = OutlineView(
volume=1,
chapter_no=3,
beats={"entities": ["林动"]},
foreshadow_windows=[{"code": "F1", "entities": ["林动"]}],
)
digests = [DigestView(chapter_no=2, facts={"entities": ["林动"]})]
trace = select_relevant_entities(
outline=outline,
characters=[char("林动", role="主角")],
world_entities=[],
recent_digests=digests,
)
selected = [e for e in trace.selected if e.name == "林动"]
assert len(selected) == 1 # 去重为一条
# 四个理由全命中且按规范顺序排列
assert selected[0].reasons == [
"explicit_beat",
"main_character",
"recent_digest",
"foreshadow_window",
]
# ---- render_cards ----
def test_render_cards_deterministic_and_sorted() -> None:
selection = SelectionTrace(
selected=[
_selected("character", ""),
_selected("character", ""),
]
)
chars = [char("", role="配角", latest_state="受伤"), char("", role="主角")]
out1 = render_cards(selection, chars, [])
out2 = render_cards(selection, chars, [])
assert out1 == out2 # 确定性
# 按名排序Python 默认 codepoint 序:乙 U+4E59 < 甲 U+7532
assert out1.index("") < out1.index("")
# ---- assemble ----
async def test_assemble_deterministic_no_timestamps() -> None:
repos = _full_repos()
ctx1 = await assemble(repos, PROJECT, 5)
ctx2 = await assemble(repos, PROJECT, 5)
assert isinstance(ctx1, AssembledContext)
assert ctx1.stable_core == ctx2.stable_core
assert ctx1.volatile == ctx2.volatile
# 无 UUID / 时间戳泄漏进缓存前缀
assert str(PROJECT) not in ctx1.stable_core
assert "T00:00" not in ctx1.stable_core
async def test_latest_state_in_volatile_not_stable() -> None:
repos = _full_repos()
ctx = await assemble(repos, PROJECT, 5)
# latest_state 是易变态——必须在断点之后,不污染缓存前缀
assert "断左臂" in ctx.volatile
assert "断左臂" not in ctx.stable_core
async def test_rule_merge_precedence() -> None:
rules = [
RuleView(level="global", content="全局规则"),
RuleView(level="project", content="作品规则"),
RuleView(level="genre", content="题材规则"),
RuleView(level="style", content="文风规则"),
]
repos = build_repos(rules=rules)
ctx = await assemble(repos, PROJECT, 1)
# 四级都出现,且 global→genre→style→project 顺序(越具体越靠后/越优先)
g = ctx.stable_core.index("全局规则")
ge = ctx.stable_core.index("题材规则")
s = ctx.stable_core.index("文风规则")
p = ctx.stable_core.index("作品规则")
assert g < ge < s < p
# ---- helpers ----
def _selected(kind: EntityKind, name: str) -> SelectedEntity:
return SelectedEntity(kind=kind, name=name, reasons=["main_character"])
def _full_repos() -> MemoryRepos:
return build_repos(
outline={
5: OutlineView(
volume=1,
chapter_no=5,
beats={"entities": ["林动"], "text": "林动突破"},
foreshadow_windows=[{"code": "F1", "entities": []}],
)
},
characters=[char("林动", role="主角", latest_state="断左臂")],
world=[world("元力", type_="力量体系")],
digests=[DigestView(chapter_no=4, facts={"entities": ["元力"]})],
foreshadows=[ForeshadowView(code="F1", title="神秘石符", status="OPEN", planted_at=1)],
style=StyleView(dimensions={"句长": "短句为主"}),
rules=[RuleView(level="global", content="全局规则")],
)

View File

@@ -0,0 +1,165 @@
"""T1.3 编排器单测——write 节点 + SSE 归一 + 图编译ARCH §5.2/§7.3)。
全部注入 mock 网关,无真 LLM、无真 Postgres图用 MemorySaver。asyncio_mode=auto。
"""
from __future__ import annotations
import uuid
from fakes_orchestrator import FakeStreamGateway, RaisingGateway
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from ww_core.orchestrator import (
EVENT_DONE,
EVENT_ERROR,
EVENT_TOKEN,
WRITE_NODE,
ChapterState,
build_write_graph,
build_write_request,
normalize_deltas,
stream_chapter_draft,
write_node,
)
from ww_shared import AppError, ErrorCode
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
STABLE = "## 世界观硬规则\n灵气可凝丹"
VOLATILE = "## 本章节拍\n主角破境"
# ---- build_write_request纯函数、缓存断点、tier 不变量 ----
def test_build_write_request_puts_stable_core_in_cached_system_block() -> None:
req = build_write_request(
stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
)
assert req.tier == "writer" # 不变量②:只传档位,不传 model
assert req.stream is True
assert len(req.system) == 1
assert req.system[0].text == STABLE
assert req.system[0].cache is True # 不变量#9稳定内核进缓存断点前
assert req.input == VOLATILE
assert req.scope.user_id == USER
assert req.scope.project_id == PROJECT
# ---- stream_chapter_draft转发网关 Delta、构造正确请求 ----
async def test_stream_chapter_draft_yields_deltas_and_builds_request() -> None:
gateway = FakeStreamGateway(["", "", "", ""])
texts = [
d.text
async for d in stream_chapter_draft(
gateway, stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
)
]
assert texts == ["", "", "", ""]
assert gateway.call_count == 1
assert gateway.last_request is not None
assert gateway.last_request.tier == "writer"
assert gateway.last_request.system[0].cache is True
# ---- write_node累积草稿、不可变增量返回 ----
async def test_write_node_accumulates_draft() -> None:
gateway = FakeStreamGateway(["第一段。", "第二段。"])
state: ChapterState = {
"project_id": PROJECT,
"chapter_no": 7,
"user_id": USER,
"stable_core": STABLE,
"volatile": VOLATILE,
"draft": "",
}
result = await write_node(state, gateway=gateway)
assert result == {"draft": "第一段。第二段。"}
assert state["draft"] == "" # 不可变:未原地改入参
# ---- SSE 归一token / done ----
async def test_normalize_deltas_emits_tokens_then_done() -> None:
gateway = FakeStreamGateway(["a", "bc", ""]) # 空 Delta 不应产 token
deltas = stream_chapter_draft(
gateway, stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
)
events = [e async for e in normalize_deltas(deltas)]
assert [e.event for e in events] == [EVENT_TOKEN, EVENT_TOKEN, EVENT_DONE]
assert events[0].data == {"text": "a"}
assert events[1].data == {"text": "bc"}
assert events[-1].data == {"length": 3} # "a"+"bc"
# ---- SSE 归一:中途失败 → error 事件(不向上抛) ----
async def test_normalize_deltas_emits_error_on_app_error() -> None:
gateway = RaisingGateway(["半句"], AppError(ErrorCode.LLM_UNAVAILABLE, "fallbacks exhausted"))
deltas = stream_chapter_draft(
gateway, stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
)
events = [e async for e in normalize_deltas(deltas, request_id="req-1")]
assert [e.event for e in events] == [EVENT_TOKEN, EVENT_ERROR] # 无 done
err = events[-1].data
assert err["code"] == ErrorCode.LLM_UNAVAILABLE
assert err["request_id"] == "req-1"
async def test_normalize_deltas_maps_unexpected_error_to_internal() -> None:
gateway = RaisingGateway([], RuntimeError("boom"))
deltas = stream_chapter_draft(
gateway, stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
)
events = [e async for e in normalize_deltas(deltas)]
assert [e.event for e in events] == [EVENT_ERROR]
assert events[0].data["code"] == ErrorCode.INTERNAL
# ---- 图编译:装节点 + checkpointerMemorySaver不连真 Postgres ----
def test_build_write_graph_compiles_with_checkpointer() -> None:
gateway = FakeStreamGateway(["x"])
graph = build_write_graph(gateway, checkpointer=MemorySaver())
assert WRITE_NODE in graph.get_graph().nodes
assert graph.checkpointer is not None
async def test_compiled_graph_runs_write_node_end_to_end() -> None:
gateway = FakeStreamGateway(["", ""])
graph = build_write_graph(gateway, checkpointer=MemorySaver())
config: RunnableConfig = {"configurable": {"thread_id": "t-1"}}
initial: ChapterState = {
"project_id": PROJECT,
"chapter_no": 1,
"user_id": USER,
"stable_core": STABLE,
"volatile": VOLATILE,
"draft": "",
}
final = await graph.ainvoke(initial, config=config)
assert final["draft"] == "云深"

View File

View File

@@ -0,0 +1,189 @@
"""章节草稿/验收 Repository 协议 + SQLAlchemy async 实现ARCH §3.3 / §5.5 / §7.2)。
- 草稿:自动保存把流式产出的文本落到 `chapters`status='draft')。幂等:同一
(project_id, chapter_no) 的草稿重复保存覆盖同一行、版次固定为草稿版次。
- 验收晋升M2/T2.3`promote_to_accepted` 计算该章 max(version)+1插入 status='accepted'
的**新行**草稿行保留符合「多版本行」§3.3)。真正「何时晋升 + 从终稿提炼 digest」
在 T2.4 验收事务里组合调用——本 repo 只提供确定性的版次计算 + 插入能力。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Protocol
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Chapter
# 草稿固定版次accepted 版本提升属 M2不在此处递增
DRAFT_STATUS = "draft"
DRAFT_VERSION = 1
ACCEPTED_STATUS = "accepted"
@dataclass(frozen=True)
class ChapterDraftView:
"""只读章节草稿快照snake_case"""
project_id: uuid.UUID
chapter_no: int
volume: int
content: str
status: str
version: int
@dataclass(frozen=True)
class ChapterView:
"""只读章节版本快照(任意 status/version验收晋升返回此形"""
project_id: uuid.UUID
chapter_no: int
volume: int
content: str
status: str
version: int
class ChapterRepo(Protocol):
"""章节草稿读写 + 验收晋升接口(按 project_id 隔离)。"""
async def save_draft(
self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1
) -> ChapterDraftView: ...
async def get_draft(
self, project_id: uuid.UUID, chapter_no: int
) -> ChapterDraftView | None: ...
async def max_version(self, project_id: uuid.UUID, chapter_no: int) -> int:
"""该章现有最大 version无任何行则 0——验收取 max+1。"""
...
async def promote_to_accepted(
self, project_id: uuid.UUID, chapter_no: int, *, content: str, volume: int = 1
) -> ChapterView:
"""从终稿晋升:插入 status='accepted'、version=max+1 的新行(草稿行保留)。"""
...
async def latest_accepted(self, project_id: uuid.UUID, chapter_no: int) -> ChapterView | None:
"""该章最新(最高 version的 accepted 版本,无则 None。"""
...
def _to_view(row: Chapter) -> ChapterDraftView:
return ChapterDraftView(
project_id=row.project_id,
chapter_no=row.chapter_no,
volume=row.volume,
content=row.content or "",
status=row.status,
version=row.version,
)
def _to_chapter_view(row: Chapter) -> ChapterView:
return ChapterView(
project_id=row.project_id,
chapter_no=row.chapter_no,
volume=row.volume,
content=row.content or "",
status=row.status,
version=row.version,
)
class SqlChapterRepo:
"""SQLAlchemy 实现:草稿幂等 upsert显式 read-modify-write版次固定"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def _find_draft(self, project_id: uuid.UUID, chapter_no: int) -> Chapter | None:
return (
await self._s.execute(
select(Chapter).where(
Chapter.project_id == project_id,
Chapter.chapter_no == chapter_no,
Chapter.version == DRAFT_VERSION,
)
)
).scalar_one_or_none()
async def save_draft(
self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1
) -> ChapterDraftView:
# 唯一约束 (project_id, chapter_no, version);草稿版次固定 → 覆盖同一行,幂等。
existing = await self._find_draft(project_id, chapter_no)
if existing is None:
row = Chapter(
project_id=project_id,
volume=volume,
chapter_no=chapter_no,
content=text,
status=DRAFT_STATUS,
version=DRAFT_VERSION,
)
self._s.add(row)
await self._s.commit()
await self._s.refresh(row)
return _to_view(row)
existing.content = text
existing.status = DRAFT_STATUS
await self._s.commit()
await self._s.refresh(existing)
return _to_view(existing)
async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> ChapterDraftView | None:
row = await self._find_draft(project_id, chapter_no)
if row is None:
return None
return _to_view(row)
async def max_version(self, project_id: uuid.UUID, chapter_no: int) -> int:
result = await self._s.execute(
select(func.max(Chapter.version)).where(
Chapter.project_id == project_id,
Chapter.chapter_no == chapter_no,
)
)
return result.scalar_one_or_none() or 0
async def promote_to_accepted(
self, project_id: uuid.UUID, chapter_no: int, *, content: str, volume: int = 1
) -> ChapterView:
# 取 max+1 晋升新 accepted 行草稿行保留多版本行§3.3)。
# 唯一约束 (project_id, chapter_no, version) 由 DB 强制。
# **只 flush 不 commit**验收事务T2.4)把晋升+digest+裁决留痕作为单事务提交。
next_version = (await self.max_version(project_id, chapter_no)) + 1
row = Chapter(
project_id=project_id,
volume=volume,
chapter_no=chapter_no,
content=content,
status=ACCEPTED_STATUS,
version=next_version,
)
self._s.add(row)
await self._s.flush()
await self._s.refresh(row)
return _to_chapter_view(row)
async def latest_accepted(self, project_id: uuid.UUID, chapter_no: int) -> ChapterView | None:
row = (
await self._s.execute(
select(Chapter)
.where(
Chapter.project_id == project_id,
Chapter.chapter_no == chapter_no,
Chapter.status == ACCEPTED_STATUS,
)
.order_by(Chapter.version.desc())
.limit(1)
)
).scalar_one_or_none()
if row is None:
return None
return _to_chapter_view(row)

View File

@@ -0,0 +1,107 @@
"""项目立项Repository 协议 + SQLAlchemy async 实现ARCH §3.5 / §7.2)。
记忆/记账依赖这里的 **Protocol**,单测注入内存 fake运行时注入 SQLAlchemy 实现。
统一按 owner_id 过滤(单用户原型 stub多租户化时由认证主体替换
视图是与 ORM 解耦的只读 Pydantic 快照——路由不碰 SQLAlchemy 行。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import Any, Protocol
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Project
@dataclass(frozen=True)
class ProjectView:
"""只读项目快照snake_case"""
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
@dataclass(frozen=True)
class ProjectCreate:
"""立项向导写入字段owner_id 由服务层补 stub"""
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 ProjectRepo(Protocol):
"""项目读写接口(按 owner_id 隔离)。"""
async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView: ...
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]: ...
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None: ...
def _to_view(row: Project) -> ProjectView:
return ProjectView(
id=row.id,
title=row.title,
genre=row.genre,
logline=row.logline,
premise=row.premise,
theme=row.theme,
selling_points=list(row.selling_points or []),
structure=row.structure,
)
class SqlProjectRepo:
"""SQLAlchemy 实现:写 `projects`,按 owner_id 过滤读取。"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView:
row = Project(
owner_id=owner_id,
title=data.title,
genre=data.genre,
logline=data.logline,
premise=data.premise,
theme=data.theme,
selling_points=list(data.selling_points),
structure=data.structure,
)
self._s.add(row)
await self._s.commit()
await self._s.refresh(row)
return _to_view(row)
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
rows = (
await self._s.execute(
select(Project).where(Project.owner_id == owner_id).order_by(Project.created_at)
)
).scalars()
return [_to_view(r) for r in rows]
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
row = (
await self._s.execute(
select(Project).where(Project.owner_id == owner_id, Project.id == project_id)
)
).scalar_one_or_none()
if row is None:
return None
return _to_view(row)

View File

@@ -0,0 +1,132 @@
"""领域视图 + Repository 协议ARCH §3.5)。
记忆服务依赖这些 **Protocol** 而非具体实现(依赖倒置):单测注入内存 fake
运行时注入 SQLAlchemy 实现(见 ww_core.memory.sql_repositories
视图是与 ORM 解耦的只读 Pydantic 快照——记忆逻辑不碰 SQLAlchemy 行,
保证确定性选择可纯函数化、可单测。所有读取统一按 project_id 过滤(数据隔离)。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Any, Protocol
from pydantic import BaseModel, Field
# ---- 只读视图snake_casefrozen 防止意外突变)----
class OutlineView(BaseModel):
model_config = {"frozen": True}
volume: int
chapter_no: int
beats: dict[str, Any] = Field(default_factory=dict)
foreshadow_windows: list[dict[str, Any]] = Field(default_factory=list)
class CharacterView(BaseModel):
model_config = {"frozen": True}
name: str
role: str | None = None
traits: dict[str, Any] | None = None
appearance: str | None = None
motive: str | None = None
backstory: str | None = None
arc: dict[str, Any] | None = None
speech_tics: dict[str, Any] | None = None
tags: list[Any] = Field(default_factory=list)
relations: list[Any] = Field(default_factory=list)
first_chapter: int | None = None
latest_state: str | None = None
class WorldEntityView(BaseModel):
model_config = {"frozen": True}
type: str
name: str
rules: dict[str, Any] | None = None
first_chapter: int | None = None
latest_state: str | None = None
class DigestView(BaseModel):
model_config = {"frozen": True}
chapter_no: int
facts: dict[str, Any] = Field(default_factory=dict)
class ForeshadowView(BaseModel):
model_config = {"frozen": True}
code: str
title: str
status: str
planted_at: int | None = None
expected_close_from: int | None = None
expected_close_to: int | None = None
content: str | None = None
class StyleView(BaseModel):
model_config = {"frozen": True}
dimensions: dict[str, Any] = Field(default_factory=dict)
class RuleView(BaseModel):
model_config = {"frozen": True}
level: str
content: str
# ---- Repository 协议async统一按 project_id 过滤)----
class OutlineRepo(Protocol):
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None: ...
class CharacterRepo(Protocol):
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]: ...
class WorldEntityRepo(Protocol):
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]: ...
class DigestRepo(Protocol):
async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]: ...
class ForeshadowRepo(Protocol):
async def list_for_codes(
self, project_id: uuid.UUID, codes: list[str]
) -> list[ForeshadowView]: ...
class StyleRepo(Protocol):
async def latest(self, project_id: uuid.UUID) -> StyleView | None: ...
class RulesRepo(Protocol):
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]: ...
@dataclass(frozen=True)
class MemoryRepos:
"""记忆服务所需的 7 个 repo 的依赖捆绑(注入点)。"""
outline: OutlineRepo
character: CharacterRepo
world_entity: WorldEntityRepo
digest: DigestRepo
foreshadow: ForeshadowRepo
style: StyleRepo
rules: RulesRepo

View File

@@ -0,0 +1,28 @@
"""记忆服务C5——确定性选择 + 卡片渲染 + prompt 组装ARCH §3.4/§5.3)。"""
from __future__ import annotations
from .assemble import assemble, merge_rules
from .render import render_cards
from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, select_relevant_entities
from .types import (
AssembledContext,
EntityKind,
SelectedEntity,
SelectionReason,
SelectionTrace,
)
__all__ = [
"MAIN_ROLES",
"RECENT_DIGEST_COUNT",
"AssembledContext",
"EntityKind",
"SelectedEntity",
"SelectionReason",
"SelectionTrace",
"assemble",
"merge_rules",
"render_cards",
"select_relevant_entities",
]

View File

@@ -0,0 +1,129 @@
"""记忆注入与 prompt 组装ARCH §5.3)。
assemble = 确定性选择 → 渲染卡片 → 序列化为 stable_core(缓存前缀) + volatile(断点后)。
稳定内核只放真正定型、无 latest_state 的内容latest_state/本章大纲等易变内容入 volatile。
全程排序、无时间戳/UUID保证缓存前缀字节稳定不变量 #9
"""
from __future__ import annotations
import json
import uuid
from typing import Any
from ww_core.domain.repositories import (
CharacterView,
DigestView,
ForeshadowView,
MemoryRepos,
OutlineView,
RuleView,
StyleView,
WorldEntityView,
)
from .render import render_cards
from .selection import MAIN_ROLES, RECENT_DIGEST_COUNT, select_relevant_entities
from .types import AssembledContext
# 四级规则合并优先级global → genre → style → project越具体越靠后/越优先)
_LEVEL_RANK: dict[str, int] = {"global": 0, "genre": 1, "style": 2, "project": 3}
def _ser(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True)
def merge_rules(rules: list[RuleView]) -> list[RuleView]:
"""按 global→genre→style→project 排序(同级按内容排序,确定性)。"""
return sorted(rules, key=lambda r: (_LEVEL_RANK.get(r.level, 99), r.level, r.content))
def _section(title: str, body: str) -> str | None:
return f"## {title}\n{body}" if body else None
def _build_stable(
world_entities: list[WorldEntityView],
main_characters: list[CharacterView],
style: StyleView | None,
rules: list[RuleView],
) -> str:
world_lines = [
f"【世界规则】{w.name}{_ser(w.rules)}"
for w in sorted(world_entities, key=lambda w: w.name)
if w.rules
]
# 定型主角:只放稳定字段,绝不含 latest_state易变
char_lines = [
f"【主角】{c.name}|定位:{c.role or '未定'}"
+ (f"|动机:{c.motive}" if c.motive else "")
+ (f"|背景:{c.backstory}" if c.backstory else "")
for c in sorted(main_characters, key=lambda c: c.name)
]
rule_lines = [f"[{r.level}] {r.content}" for r in merge_rules(rules)]
sections = [
_section("世界观硬规则", "\n".join(world_lines)),
_section("定型主角", "\n".join(char_lines)),
_section("文风指纹", _ser(style.dimensions) if style else ""),
_section("规则", "\n".join(rule_lines)),
]
return "\n\n".join(s for s in sections if s)
def _build_volatile(
cards: str,
foreshadows: list[ForeshadowView],
digests: list[DigestView],
beats: dict[str, Any],
) -> str:
fore_lines = [
f"【伏笔】{f.code} {f.title} [{f.status}]"
for f in sorted(foreshadows, key=lambda f: f.code)
]
digest_lines = [
f"{d.chapter_no}章:{_ser(d.facts)}" for d in sorted(digests, key=lambda d: d.chapter_no)
]
sections = [
_section("本章注入卡片", cards),
_section("相关伏笔窗口", "\n".join(fore_lines)),
_section("近况摘要", "\n".join(digest_lines)),
_section("本章节拍", _ser(beats) if beats else ""),
]
return "\n\n".join(s for s in sections if s)
async def assemble(
repos: MemoryRepos,
project_id: uuid.UUID,
chapter_no: int,
recent_k: int = RECENT_DIGEST_COUNT,
) -> AssembledContext:
"""组装本章 prompt 上下文(确定性、可缓存)。"""
outline = await repos.outline.get(project_id, chapter_no) or OutlineView(
volume=0, chapter_no=chapter_no
)
characters = await repos.character.list_for_project(project_id)
world_entities = await repos.world_entity.list_for_project(project_id)
recent_digests = await repos.digest.recent(project_id, recent_k)
selection = select_relevant_entities(
outline=outline,
characters=characters,
world_entities=world_entities,
recent_digests=recent_digests,
)
codes = [str(w["code"]) for w in outline.foreshadow_windows if w.get("code")]
foreshadows = await repos.foreshadow.list_for_codes(project_id, codes)
style = await repos.style.latest(project_id)
rules = await repos.rules.all_for_project(project_id)
main_characters = [c for c in characters if (c.role or "") in MAIN_ROLES]
stable_core = _build_stable(world_entities, main_characters, style, rules)
cards = render_cards(selection, characters, world_entities)
volatile = _build_volatile(cards, foreshadows, recent_digests, outline.beats)
return AssembledContext(stable_core=stable_core, volatile=volatile, selection=selection)

View File

@@ -0,0 +1,62 @@
"""把选中实体渲染成「角色/设定卡」文本块ARCH §3.4 渲染为卡片)。
卡片含 latest_state易变态——故渲染产物归入 volatile不进缓存前缀。
确定性:按 SelectionTrace 既定顺序渲染;字段缺省则略过该行。
"""
from __future__ import annotations
import json
from typing import Any
from ww_core.domain.repositories import CharacterView, WorldEntityView
from .types import SelectionTrace
def _serialize(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True)
def _line(label: str, value: str | None) -> str | None:
if value is None or value == "":
return None
return f"- {label}{value}"
def _character_card(c: CharacterView) -> str:
head = f"【角色】{c.name}|定位:{c.role or '未定'}"
lines = [
_line("外貌", c.appearance),
_line("动机", c.motive),
_line("说话习惯", _serialize(c.speech_tics) if c.speech_tics else None),
_line("当前状态", c.latest_state),
]
return "\n".join([head, *[ln for ln in lines if ln]])
def _world_card(w: WorldEntityView) -> str:
head = f"【设定·{w.type}{w.name}"
lines = [
_line("规则", _serialize(w.rules) if w.rules else None),
_line("当前状态", w.latest_state),
]
return "\n".join([head, *[ln for ln in lines if ln]])
def render_cards(
selection: SelectionTrace,
characters: list[CharacterView],
world_entities: list[WorldEntityView],
) -> str:
"""按选择顺序渲染卡片;找不到底层行的选中项跳过。"""
char_by_name = {c.name: c for c in characters}
world_by_name = {w.name: w for w in world_entities}
cards: list[str] = []
for e in sorted(selection.selected, key=lambda e: (e.kind, e.name)):
if e.kind == "character" and e.name in char_by_name:
cards.append(_character_card(char_by_name[e.name]))
elif e.kind == "world_entity" and e.name in world_by_name:
cards.append(_world_card(world_by_name[e.name]))
return "\n\n".join(cards)

View File

@@ -0,0 +1,112 @@
"""确定性记忆选择ARCH §3.4)——无向量,纯函数、可单测、可调试。
选择来源(并集去重):①显式引用(beats 点名) ②主角常驻 ③近况实体(近 N 章摘要)
④命中本章伏笔窗口。pg_trgm 按名匹配兜底属 P-later此处不做。
"""
from __future__ import annotations
from typing import Any
from ww_core.domain.repositories import CharacterView, DigestView, OutlineView, WorldEntityView
from .types import EntityKind, SelectedEntity, SelectionReason, SelectionTrace
# 视为常驻主角的 role 取值
MAIN_ROLES: frozenset[str] = frozenset({"主角", "核心", "protagonist", "lead"})
# 近况实体回看的章数ARCH §3.4 默认 35
RECENT_DIGEST_COUNT = 5
# 选择结果排序时的 kind 次序
_KIND_RANK: dict[EntityKind, int] = {"character": 0, "world_entity": 1}
_REASON_RANK: dict[SelectionReason, int] = {
"explicit_beat": 0,
"main_character": 1,
"recent_digest": 2,
"foreshadow_window": 3,
}
def _flatten_strings(obj: Any) -> list[str]:
"""递归收集任意 JSON 结构里的字符串叶子(确定性、用于按名匹配)。"""
if isinstance(obj, str):
return [obj]
if isinstance(obj, dict):
out: list[str] = []
for key in sorted(obj):
out.extend(_flatten_strings(obj[key]))
return out
if isinstance(obj, (list, tuple)):
out = []
for item in obj:
out.extend(_flatten_strings(item))
return out
return []
def _explicit_names(beats: dict[str, Any]) -> tuple[set[str], str]:
"""返回 (显式列出的实体名集合, beats 扁平文本) 供按名匹配。"""
listed = {str(n) for n in beats.get("entities", []) if isinstance(n, str)}
text = "\n".join(_flatten_strings({k: v for k, v in beats.items() if k != "entities"}))
return listed, text
def _digest_names(digests: list[DigestView]) -> tuple[set[str], str]:
listed: set[str] = set()
texts: list[str] = []
for d in digests:
listed |= {str(n) for n in d.facts.get("entities", []) if isinstance(n, str)}
texts.extend(_flatten_strings({k: v for k, v in d.facts.items() if k != "entities"}))
return listed, "\n".join(texts)
def _window_entity_names(outline: OutlineView) -> set[str]:
names: set[str] = set()
for win in outline.foreshadow_windows:
for n in win.get("entities", []):
if isinstance(n, str):
names.add(n)
return names
def _matches(name: str, listed: set[str], text: str) -> bool:
return name in listed or (name in text)
def select_relevant_entities(
*,
outline: OutlineView,
characters: list[CharacterView],
world_entities: list[WorldEntityView],
recent_digests: list[DigestView],
) -> SelectionTrace:
"""确定性并集选择 → SelectionTrace每实体带去重、排序的入选理由"""
beat_listed, beat_text = _explicit_names(outline.beats)
digest_listed, digest_text = _digest_names(recent_digests)
window_names = _window_entity_names(outline)
selected: list[SelectedEntity] = []
def reasons_for(name: str, *, is_main: bool) -> list[SelectionReason]:
reasons: list[SelectionReason] = []
if _matches(name, beat_listed, beat_text):
reasons.append("explicit_beat")
if is_main:
reasons.append("main_character")
if _matches(name, digest_listed, digest_text):
reasons.append("recent_digest")
if name in window_names:
reasons.append("foreshadow_window")
return sorted(set(reasons), key=lambda r: _REASON_RANK[r])
for c in characters:
reasons = reasons_for(c.name, is_main=(c.role or "") in MAIN_ROLES)
if reasons:
selected.append(SelectedEntity(kind="character", name=c.name, reasons=reasons))
for w in world_entities:
reasons = reasons_for(w.name, is_main=False)
if reasons:
selected.append(SelectedEntity(kind="world_entity", name=w.name, reasons=reasons))
selected.sort(key=lambda e: (_KIND_RANK[e.kind], e.name))
return SelectionTrace(selected=selected)

View File

@@ -0,0 +1,190 @@
"""Repository 协议的 SQLAlchemy async 实现ARCH §3.5)。
ORM 行 → 只读视图的映射层;统一按 project_id 过滤(数据隔离,多租户化时此层加 owner_id
单测用内存 fake见 tests此实现由 mypy 静态校验、运行时T1.4)注入真实 session。
"""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import (
ChapterDigest,
Character,
Foreshadow,
Outline,
Rule,
StyleFingerprint,
WorldEntity,
)
from ww_core.domain.repositories import (
CharacterView,
DigestView,
ForeshadowView,
MemoryRepos,
OutlineView,
RuleView,
StyleView,
WorldEntityView,
)
class SqlOutlineRepo:
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
row = (
await self._s.execute(
select(Outline).where(
Outline.project_id == project_id, Outline.chapter_no == chapter_no
)
)
).scalar_one_or_none()
if row is None:
return None
return OutlineView(
volume=row.volume,
chapter_no=row.chapter_no,
beats=row.beats or {},
foreshadow_windows=list(row.foreshadow_windows or []),
)
class SqlCharacterRepo:
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
rows = (
await self._s.execute(select(Character).where(Character.project_id == project_id))
).scalars()
return [
CharacterView(
name=r.name,
role=r.role,
traits=r.traits,
appearance=r.appearance,
motive=r.motive,
backstory=r.backstory,
arc=r.arc,
speech_tics=r.speech_tics,
tags=list(r.tags or []),
relations=list(r.relations or []),
first_chapter=r.first_chapter,
latest_state=r.latest_state,
)
for r in rows
]
class SqlWorldEntityRepo:
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
rows = (
await self._s.execute(select(WorldEntity).where(WorldEntity.project_id == project_id))
).scalars()
return [
WorldEntityView(
type=r.type,
name=r.name,
rules=r.rules,
first_chapter=r.first_chapter,
latest_state=r.latest_state,
)
for r in rows
]
class SqlDigestRepo:
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]:
rows = (
await self._s.execute(
select(ChapterDigest)
.where(ChapterDigest.project_id == project_id)
.order_by(ChapterDigest.chapter_no.desc())
.limit(k)
)
).scalars()
return [DigestView(chapter_no=r.chapter_no, facts=r.facts) for r in rows]
class SqlForeshadowRepo:
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]:
if not codes:
return []
rows = (
await self._s.execute(
select(Foreshadow).where(
Foreshadow.project_id == project_id, Foreshadow.code.in_(codes)
)
)
).scalars()
return [
ForeshadowView(
code=r.code,
title=r.title,
status=r.status,
planted_at=r.planted_at,
expected_close_from=r.expected_close_from,
expected_close_to=r.expected_close_to,
content=r.content,
)
for r in rows
]
class SqlStyleRepo:
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def latest(self, project_id: uuid.UUID) -> StyleView | None:
row = (
await self._s.execute(
select(StyleFingerprint)
.where(StyleFingerprint.project_id == project_id)
.order_by(StyleFingerprint.version.desc())
.limit(1)
)
).scalar_one_or_none()
if row is None:
return None
return StyleView(dimensions=row.dimensions_json)
class SqlRulesRepo:
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
# 全局规则(project_id 为空) + 本作品规则——合并优先级在 assemble.merge_rules 处理
rows = (
await self._s.execute(
select(Rule).where((Rule.project_id == project_id) | (Rule.project_id.is_(None)))
)
).scalars()
return [RuleView(level=r.level, content=r.content) for r in rows]
def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
"""用一个 AsyncSession 装配全部 7 个 SQLAlchemy repoT1.4 注入点)。"""
return MemoryRepos(
outline=SqlOutlineRepo(session),
character=SqlCharacterRepo(session),
world_entity=SqlWorldEntityRepo(session),
digest=SqlDigestRepo(session),
foreshadow=SqlForeshadowRepo(session),
style=SqlStyleRepo(session),
rules=SqlRulesRepo(session),
)

View File

@@ -0,0 +1,60 @@
"""记忆服务输出契约C5——AssembledContext / SelectionTrace。
决策memory/decisions.md 2026-06-18assemble 返回中性序列化文本,
不返回 gateway 的 LlmRequest——记忆服务只经 DB 通信,不依赖网关类型。
write 节点(T1.3)再据此构造 LlmRequest(system=[Block(stable_core, cache=True)], input=volatile)。
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
EntityKind = Literal["character", "world_entity"]
SelectionReason = Literal[
"explicit_beat", # 本章大纲 beats 点名
"main_character", # 主角/核心常驻
"recent_digest", # 近 N 章摘要出现
"foreshadow_window", # 命中本章伏笔窗口
]
# 理由的规范排序(保证 SelectionTrace 确定性)
REASON_ORDER: tuple[SelectionReason, ...] = (
"explicit_beat",
"main_character",
"recent_digest",
"foreshadow_window",
)
class SelectedEntity(BaseModel):
"""一个被选中的实体 + 入选理由(喂给 UX 注入透明面板T1.6)。"""
model_config = {"frozen": True}
kind: EntityKind
name: str
reasons: list[SelectionReason] = Field(default_factory=list)
class SelectionTrace(BaseModel):
"""确定性选择的可解释结果:选了谁、为什么。"""
model_config = {"frozen": True}
selected: list[SelectedEntity] = Field(default_factory=list)
class AssembledContext(BaseModel):
"""组装结果:缓存断点前的稳定内核 + 断点后的易变内容 + 选择留痕。
stable_core / volatile 均为**已确定性排序、无时间戳/无 UUID** 的字符串
缓存前缀字节稳定ARCH §4.6 / 不变量 #9
"""
model_config = {"frozen": True}
stable_core: str
volatile: str
selection: SelectionTrace

View File

@@ -0,0 +1,97 @@
"""编排器C4 / ARCH §5.2)——写章图 + 审稿子图 + SSE 归一 + Postgres checkpointer。
- M1单 `write` 节点(`build_write_graph`)。
- M2并行审子图`build_review_graph`START→各审并行→collect 落 `chapter_reviews`+
审稿 SSE`section`/`conflict` 事件 + `normalize_review`)。
- accept验收事务/`interrupt_before`)不在本图——属 T2.4确定性代码读领域表R3
"""
from __future__ import annotations
from .collect import (
CONTINUITY,
ReviewRecorder,
collect_reviews,
extract_conflicts,
)
from .graph import (
COLLECT_NODE,
REVIEW_SPECS,
WRITE_NODE,
build_review_graph,
build_write_graph,
setup_checkpointer,
)
from .review_node import (
REVIEW_INCOMPLETE,
REVIEW_OK,
BoundReviewNode,
GatewayRun,
build_review_context,
build_review_request,
make_review_node,
run_review,
)
from .sse import (
EVENT_CONFLICT,
EVENT_DONE,
EVENT_ERROR,
EVENT_SECTION,
EVENT_TOKEN,
SECTION_DONE,
SECTION_INCOMPLETE,
SECTION_STARTED,
SseEvent,
conflict_event,
done_event,
error_event,
normalize_deltas,
normalize_review,
section_event,
token_event,
)
from .state import ChapterState, merge_reviews
from .write_node import GatewayStream, build_write_request, stream_chapter_draft, write_node
__all__ = [
"COLLECT_NODE",
"CONTINUITY",
"EVENT_CONFLICT",
"EVENT_DONE",
"EVENT_ERROR",
"EVENT_SECTION",
"EVENT_TOKEN",
"REVIEW_INCOMPLETE",
"REVIEW_OK",
"REVIEW_SPECS",
"SECTION_DONE",
"SECTION_INCOMPLETE",
"SECTION_STARTED",
"WRITE_NODE",
"BoundReviewNode",
"ChapterState",
"GatewayRun",
"GatewayStream",
"ReviewRecorder",
"SseEvent",
"build_review_context",
"build_review_graph",
"build_review_request",
"build_write_graph",
"build_write_request",
"collect_reviews",
"conflict_event",
"done_event",
"error_event",
"extract_conflicts",
"make_review_node",
"merge_reviews",
"normalize_deltas",
"normalize_review",
"run_review",
"section_event",
"setup_checkpointer",
"stream_chapter_draft",
"token_event",
"write_node",
]

View File

@@ -0,0 +1,100 @@
"""写章图工厂 + Postgres checkpointerC4 / ARCH §5.2)。
M1单 `write` 节点图(四审/collect/interrupt 验收属 M2
checkpointer 纪律CLAUDE.md「LangGraph」
- 用 **Postgres checkpointer**durable/resumable跨 write→accept 的请求间隙)。
- `setup()` **只在 migrations/CI 跑**,绝不在 import/app-runtime 跑——故本模块只
暴露 `setup_checkpointer` 入口build 函数不触碰 DDL。
- 单测用 `MemorySaver`(或直接测节点函数),永不连真 Postgres。
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any
from langgraph.graph import END, START, StateGraph
from ww_agents import AgentSpec, continuity_spec
from .collect import ReviewRecorder, collect_reviews
from .review_node import GatewayRun, run_review
from .state import ChapterState
from .write_node import GatewayStream, write_node
if TYPE_CHECKING:
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.graph.state import CompiledStateGraph
WRITE_NODE = "write"
COLLECT_NODE = "collect"
def build_write_graph(
gateway: GatewayStream,
*,
checkpointer: BaseCheckpointSaver[Any] | None = None,
) -> CompiledStateGraph[ChapterState, None, ChapterState, ChapterState]:
"""构建并编译单 `write` 节点图。
`gateway` 经闭包绑进节点(节点对图只暴露 `(state)->dict`,确定性可单测)。
`checkpointer`:运行时传 Postgres saver单测传 `MemorySaver` 或不传。
"""
async def _write(state: ChapterState) -> dict[str, str]:
return await write_node(state, gateway=gateway)
builder: StateGraph[ChapterState, None, ChapterState, ChapterState] = StateGraph(ChapterState)
builder.add_node(WRITE_NODE, _write)
builder.add_edge(START, WRITE_NODE)
builder.add_edge(WRITE_NODE, END)
return builder.compile(checkpointer=checkpointer)
REVIEW_SPECS: tuple[AgentSpec, ...] = (continuity_spec,)
def build_review_graph(
gateway: GatewayRun,
review_repo: ReviewRecorder,
*,
review_specs: Sequence[AgentSpec] = REVIEW_SPECS,
checkpointer: BaseCheckpointSaver[Any] | None = None,
) -> CompiledStateGraph[ChapterState, None, ChapterState, ChapterState]:
"""构建并编译审稿子图:`START →`(各 review spec 并行节点)`→ collect → END`。
M2 默认仅 continuity`review_specs` 可扩M3/M4 加 foreshadow/style/pace 三审),
图按这组 spec 循环加并行分支,全部汇入 collect任一审失败不阻塞§5.2——失败隔离
在 `run_review` 内、标 incomplete 占位)。
`gateway` / `review_repo` 经闭包绑进节点langgraph 不收 functools.partial见 gotcha
**accept 不在本图**:本任务只交付到 collect审稿留痕落 `chapter_reviews`+ review SSE
`interrupt_before=["accept"]` / accept 节点属 T2.4验收事务确定性代码读领域表R3
"""
async def _collect(state: ChapterState) -> dict[str, Any]:
return await collect_reviews(state, review_repo=review_repo)
builder: StateGraph[ChapterState, None, ChapterState, ChapterState] = StateGraph(ChapterState)
builder.add_node(COLLECT_NODE, _collect)
for spec in review_specs:
# spec 经默认参绑定(避免闭包晚绑定 + langgraph 不收 functools.partial见 gotcha
async def _review(state: ChapterState, *, _spec: AgentSpec = spec) -> dict[str, Any]:
return await run_review(_spec, state, gateway=gateway)
builder.add_node(spec.name, _review)
builder.add_edge(START, spec.name)
builder.add_edge(spec.name, COLLECT_NODE)
builder.add_edge(COLLECT_NODE, END)
return builder.compile(checkpointer=checkpointer)
async def setup_checkpointer(conn_string: str) -> None:
"""在 **migrations/CI** 创建 checkpointer 所需表(绝不在 app-runtime 调用)。
用 async saver 的上下文管理器拿连接后跑一次性 `setup()` DDL。
"""
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
async with AsyncPostgresSaver.from_conn_string(conn_string) as saver:
await saver.setup()

View File

@@ -0,0 +1,167 @@
"""SSE 归一层C4 / ARCH §7.3)——把网关 `Delta` 流归一为 SSE 事件对象。
§7.3 全量事件:`token`/`section`/`conflict`/`done`/`error`。**M1 写章只需 `token`/`done`/`error`**
`section`/`conflict` 属 M2 四审)。本模块产出**与传输无关的事件对象**——
HTTP `StreamingResponse`/`text/event-stream` 编码归 T1.4apps/api不在此处。
错误处理CLAUDE.md「Logging」底层流抛错 → 发一条 `error` 事件(带 `code`/`message`/
`request_id`,对齐错误信封 §7.1)后**正常收尾**,绝不把异常泄到调用方循环里。
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Mapping
from typing import Any
import structlog
from pydantic import BaseModel
from ww_llm_gateway.types import Delta
from ww_shared import AppError, ErrorCode
from .review_node import REVIEW_OK
log = structlog.get_logger(__name__)
# §7.3 事件名(全集)。完整清单以 ARCHITECTURE §7.3 为准。
EVENT_TOKEN = "token" # 正文增量(写章 draft
EVENT_SECTION = "section" # 四审分项开始/完成(审稿 review
EVENT_CONFLICT = "conflict" # 冲突命中(审稿 review
EVENT_DONE = "done"
EVENT_ERROR = "error"
# section 状态(对齐 review_node.REVIEW_OK / REVIEW_INCOMPLETE外加分项「开始」
SECTION_STARTED = "started"
SECTION_DONE = "done"
SECTION_INCOMPLETE = "incomplete"
class SseEvent(BaseModel):
"""归一后的 SSE 事件对象snake_caseT1.4 据此编码为 text/event-stream。"""
model_config = {"frozen": True}
event: str
data: dict[str, object]
def token_event(text: str) -> SseEvent:
return SseEvent(event=EVENT_TOKEN, data={"text": text})
def done_event(*, length: int) -> SseEvent:
"""完成事件:只带长度(不回灌全文——脱敏 + 前端已逐 token 累积)。"""
return SseEvent(event=EVENT_DONE, data={"length": length})
def section_event(*, name: str, status: str) -> SseEvent:
"""四审分项事件§7.3 `section``{name, status}`——前端报告分区进度。"""
return SseEvent(event=EVENT_SECTION, data={"name": name, "status": status})
def conflict_event(
*,
type: str,
where: str,
refs: list[str],
suggestion: str,
) -> SseEvent:
"""冲突命中事件§7.3 `conflict`)——形对齐 C6 `Conflict`,供正文就地朱砂标注。"""
return SseEvent(
event=EVENT_CONFLICT,
data={"type": type, "where": where, "refs": refs, "suggestion": suggestion},
)
def error_event(*, code: str, message: str, request_id: str | None = None) -> SseEvent:
"""错误事件,形对齐错误信封 §7.1`code`/`message`),附 `request_id` 便于贯通排查。"""
return SseEvent(
event=EVENT_ERROR,
data={"code": code, "message": message, "request_id": request_id},
)
async def normalize_deltas(
deltas: AsyncIterator[Delta],
*,
request_id: str | None = None,
) -> AsyncIterator[SseEvent]:
"""把 `Delta` 流归一为 SSE 事件流:每个非空 `Delta` → `token`;正常收尾发 `done`
底层异常 → `error`(不再向上抛)。
这是 **T1.4 消费的缝**apps/api 拿 `stream_chapter_draft(...)` 传进来,
本函数产出 `SseEvent`T1.4 包成 `StreamingResponse`。
"""
total = 0
try:
async for delta in deltas:
if delta.text:
total += len(delta.text)
yield token_event(delta.text)
except AppError as exc:
log.warning(
"sse_stream_error",
code=str(exc.code),
request_id=request_id,
chars_before_error=total,
)
yield error_event(code=str(exc.code), message=exc.message, request_id=request_id)
return
except Exception as exc: # noqa: BLE001 — 边界兜底:任何意外都归一为 error 事件,不泄异常
log.error(
"sse_stream_unexpected_error",
error=str(exc),
request_id=request_id,
chars_before_error=total,
)
yield error_event(
code=str(ErrorCode.INTERNAL),
message="internal error during streaming",
request_id=request_id,
)
return
yield done_event(length=total)
async def normalize_review(
reviews: Mapping[str, Any],
*,
request_id: str | None = None,
) -> AsyncIterator[SseEvent]:
"""把审稿子图的结构化产出(`state["reviews"]`)归一为 SSE 事件序列。
供 **T2.5** 的 `POST .../review` 端点消费:跑 `build_review_graph` 后拿 `final["reviews"]`
(形 `{name: {status, result}}`),本函数产 `section`(每审 done/incomplete+ `conflict`
(命中冲突,形对齐 C6 `Conflict`+ `done`。HTTP event-stream 编码归 T2.5,不在此处。
错误处理纪律(同 `normalize_deltas`):任何意外 → 一条 `error` 事件后正常收尾,不上抛。
某审 `incomplete`网关失败被隔离§5.2)→ 发 `section{status:incomplete}`,不发其冲突。
`reviews` 键按字典序遍历 → 确定性事件顺序(便于单测/前端稳定渲染)。
"""
try:
section_count = 0
for name in sorted(reviews.keys()):
entry = reviews.get(name) or {}
status = entry.get("status")
if status == REVIEW_OK:
yield section_event(name=name, status=SECTION_DONE)
section_count += 1
result = entry.get("result") or {}
for conflict in result.get("conflicts") or []:
yield conflict_event(
type=conflict.get("type", ""),
where=conflict.get("where", ""),
refs=list(conflict.get("refs") or []),
suggestion=conflict.get("suggestion", ""),
)
else:
yield section_event(name=name, status=SECTION_INCOMPLETE)
section_count += 1
except Exception as exc: # noqa: BLE001 — 边界兜底:任何意外都归一为 error 事件,不泄异常
log.error("sse_review_unexpected_error", error=str(exc), request_id=request_id)
yield error_event(
code=str(ErrorCode.INTERNAL),
message="internal error during review",
request_id=request_id,
)
return
yield done_event(length=section_count)

View File

@@ -0,0 +1,48 @@
"""编排器图状态C4 / ARCH §5.2)——只存**控制流 + 组装上下文 + 累积草稿**。
不变量 #5checkpoint 只承载控制流位置 + 组装上下文 + 已生成草稿增量 + 过程态审稿产物,
正文/审稿以领域表为权威resume 时从 `chapters`/`chapter_reviews` 重读M2
M2 加并行审 + collect验收/interrupt 的真相源重建归 T2.4accept 不在本图)。
`ChapterState` 用 TypedDictLangGraph 原生状态形态),字段全 snake_case。
"""
from __future__ import annotations
import uuid
from typing import Annotated, Any, TypedDict
def merge_reviews(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]:
"""并行审分支的 `reviews` 归并 reducer不可变返回新 dict
四审为并行分支,同一 superstep 各写 `{spec.name: ...}`;无 reducer 时 LangGraph
会因并发写同一键抛 `InvalidUpdateError`。本 reducer 把各分项浅合并入一个 dict。
"""
return {**left, **right}
class ChapterState(TypedDict, total=False):
"""写章 + 审稿图的控制流状态。
- `project_id` / `chapter_no`定位本章resume 时据此重读领域表)。
- `user_id`:调用作用域(落账 owner_id原型单用户 stub
- `stable_core` / `volatile`:来自 `memory.assemble` 的中性文本
stable_core=缓存断点前volatile=断点后。write 节点据此构造 `LlmRequest`。
- `draft`write 节点累积的草稿正文(流式增量拼接的最终结果)。
- `review_context`M2审稿注入文本——草稿 + 近况摘要/人物卡/世界观硬规则的
确定性拼装(复用 assemble 的 stable/volatile无时间戳
- `reviews`M2collect 汇总的各审分项结果(**过程态**)。落 `chapter_reviews`
表后真相在表;不变量 #5checkpoint 不作审稿真相源resume 从领域表重读。
M2 起字段按节点逐步填充,故 `total=False`write 阶段无 review 字段亦合法)。
"""
project_id: uuid.UUID
chapter_no: int
user_id: uuid.UUID
stable_core: str
volatile: str
draft: str
review_context: str
reviews: Annotated[dict[str, Any], merge_reviews]

View File

@@ -0,0 +1,89 @@
"""write 节点C4 / ARCH §5.2 §5.4 writer 行)——把组装上下文流式写成草稿。
确定性边界CLAUDE.md「LangGraph」纪律
- 节点逻辑里**无 LLM 非确定性**——所有不确定性藏在网关后;节点只做
`AssembledContext → LlmRequest` 的纯构造 + 转发 `Gateway.stream` 的 `Delta`。
- 因此 `build_write_request` 是纯函数、`stream_chapter_draft` 注入 mock 网关即可单测,
不需要图运行时、不需要真 Postgres。
- 瞬时失败重试在网关§4.5不在节点节点遇错只干净抛出SSE 层据此发 `error`§7.3)。
不变量 ②:只传 `tier="writer"`,绝不传具体 model。
不变量 #9`stable_core` 进 `system` 缓存断点前cache=True`volatile` 进 `input`(断点后)。
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from typing import Protocol
from ww_llm_gateway.types import Block, Delta, LlmRequest, Scope
from .state import ChapterState
class GatewayStream(Protocol):
"""write 节点对网关的最小依赖——只需 `stream`(注入真网关或 mock"""
def stream(self, req: LlmRequest) -> AsyncIterator[Delta]: ...
def build_write_request(
*,
stable_core: str,
volatile: str,
user_id: uuid.UUID,
project_id: uuid.UUID,
) -> LlmRequest:
"""据组装上下文构造写章请求(纯函数,决策 2026-06-18
`stable_core` → `system` 缓存断点前块cache=True`volatile` → `input`(断点后)。
"""
return LlmRequest(
tier="writer",
system=[Block(text=stable_core, cache=True)],
input=volatile,
stream=True,
scope=Scope(user_id=user_id, project_id=project_id),
)
async def stream_chapter_draft(
gateway: GatewayStream,
*,
stable_core: str,
volatile: str,
user_id: uuid.UUID,
project_id: uuid.UUID,
) -> AsyncIterator[Delta]:
"""流式生成本章草稿增量T1.4 的底层流)——构造请求 → 转发网关 `Delta`。
本身不累积/不落库;累积归 `write_node`(图状态),落库(自动保存)归 T1.4。
瞬时失败已在网关重试;此处的异常向上抛给 SSE 归一层(发 `error` 事件)。
"""
req = build_write_request(
stable_core=stable_core,
volatile=volatile,
user_id=user_id,
project_id=project_id,
)
async for delta in gateway.stream(req):
yield delta
async def write_node(state: ChapterState, *, gateway: GatewayStream) -> dict[str, str]:
"""LangGraph write 节点:流式生成 → 累积草稿 → 不可变返回 `{"draft": ...}`。
直接调用本函数(注入 mock 网关)即可单测,无需图运行时。
返回**增量字典**LangGraph 合并进状态),不原地改 `state`(不可变更新)。
"""
parts: list[str] = []
async for delta in stream_chapter_draft(
gateway,
stable_core=state["stable_core"],
volatile=state["volatile"],
user_id=state["user_id"],
project_id=state["project_id"],
):
parts.append(delta.text)
return {"draft": "".join(parts)}

View File

@@ -0,0 +1,2 @@
# core (@llm/@backend) — 占位骨架
领域核心(domain/状态机)、编排器(orchestrator/LangGraph 图)、记忆服务(memory)。Phase 1+ 由对应 owner 填充。

View File

@@ -0,0 +1,24 @@
[project]
name = "ww-llm-gateway"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = [
"openai>=1.40",
"instructor>=1.5",
"pydantic>=2.7",
"ww-shared",
"ww-config",
"ww-db",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["ww_llm_gateway"]
[tool.uv.sources]
ww-shared = { workspace = true }
ww-config = { workspace = true }
ww-db = { workspace = true }

View File

@@ -0,0 +1,18 @@
"""网关单测公用 fixtures。"""
from __future__ import annotations
import uuid
import pytest
from ww_llm_gateway.types import LlmRequest, Scope
@pytest.fixture
def scope() -> Scope:
return Scope(user_id=uuid.UUID(int=1), project_id=uuid.UUID(int=2))
@pytest.fixture
def req(scope: Scope) -> LlmRequest:
return LlmRequest(tier="writer", input="写第 1 章", scope=scope)

View File

@@ -0,0 +1,56 @@
"""网关单测替身mock provider + 内存 ledger——不碰真实 API / DB。
放在独立模块(非 conftest以便测试用绝对导入 `from fakes import ...`
本目录无 __init__.py避免与顶层 tests 包同名冲突),故走 pytest 路径注入。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from ww_llm_gateway.adapters.base import (
Capabilities,
ProviderResult,
ProviderUsage,
StreamChunk,
)
from ww_llm_gateway.routing import Route
from ww_llm_gateway.types import LlmRequest, Scope, Tier, Usage
class FakeAdapter:
provider = "deepseek"
def __init__(self, text: str = "hello world", deltas: list[str] | None = None) -> None:
self.text = text
self.deltas = deltas if deltas is not None else ["hel", "lo ", "world"]
self.complete_calls = 0
self.stream_calls = 0
def capabilities(self) -> Capabilities:
return Capabilities(structured_output=True, prefix_cache=True)
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
self.complete_calls += 1
return ProviderResult(
text=self.text,
usage=ProviderUsage(input_tokens=100, output_tokens=50),
)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
self.stream_calls += 1
for d in self.deltas:
yield StreamChunk(text=d)
yield StreamChunk(usage=ProviderUsage(input_tokens=100, output_tokens=50))
class FakeLedger:
def __init__(self) -> None:
self.records: list[Usage] = []
async def record(self, scope: Scope, usage: Usage) -> None:
self.records.append(usage)
def fake_route(tier: Tier) -> Route:
return Route(provider="deepseek", model="deepseek-chat")

View File

@@ -0,0 +1,82 @@
"""T1.1 网关核心单测run / stream / 记账 / 路由 / 错误。"""
from __future__ import annotations
import uuid
import pytest
from fakes import FakeAdapter, FakeLedger, fake_route
from ww_llm_gateway.gateway import Gateway
from ww_llm_gateway.routing import resolve_route
from ww_llm_gateway.types import LlmRequest, Scope
from ww_shared import AppError, ErrorCode
async def test_run_returns_text_usage_and_served_by(req: LlmRequest) -> None:
adapter = FakeAdapter(text="第一章正文")
ledger = FakeLedger()
gw = Gateway({"deepseek": adapter}, ledger, resolver=fake_route)
resp = await gw.run(req)
assert resp.text == "第一章正文"
assert resp.served_by.provider == "deepseek"
assert resp.served_by.fell_back is False
assert resp.usage.input_tokens == 100
assert resp.usage.output_tokens == 50
assert adapter.complete_calls == 1
async def test_run_writes_exactly_one_ledger_record(req: LlmRequest) -> None:
ledger = FakeLedger()
gw = Gateway({"deepseek": FakeAdapter()}, ledger, resolver=fake_route)
await gw.run(req)
assert len(ledger.records) == 1
assert ledger.records[0].provider == "deepseek"
assert ledger.records[0].model == "deepseek-chat"
async def test_run_computes_cost_from_pricing(req: LlmRequest) -> None:
ledger = FakeLedger()
gw = Gateway({"deepseek": FakeAdapter()}, ledger, resolver=fake_route)
resp = await gw.run(req)
# ceil(100/1e6*27 + 50/1e6*110) == 1 cent
assert resp.usage.cost_minor == 1
assert resp.usage.currency == "USD"
async def test_stream_yields_deltas_and_records_once(req: LlmRequest) -> None:
ledger = FakeLedger()
gw = Gateway({"deepseek": FakeAdapter(deltas=["a", "b", "c"])}, ledger, resolver=fake_route)
collected = [d.text async for d in gw.stream(req)]
assert "".join(collected) == "abc"
assert len(ledger.records) == 1
assert ledger.records[0].output_tokens == 50
async def test_unknown_provider_raises_llm_unavailable(req: LlmRequest) -> None:
gw = Gateway({}, FakeLedger(), resolver=fake_route)
with pytest.raises(AppError) as exc:
await gw.run(req)
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
def test_resolve_route_parses_tier_defaults() -> None:
route = resolve_route("writer")
assert route.provider == "deepseek"
assert route.model == "deepseek-chat"
def test_agent_passes_only_tier_never_model() -> None:
# 不变量 ②LlmRequest 无 model 字段,只有 tier
req = LlmRequest(tier="analyst", input="x", scope=Scope(user_id=uuid.UUID(int=9)))
assert "model" not in LlmRequest.model_fields
assert req.tier == "analyst"

View File

@@ -0,0 +1,102 @@
"""OpenAI 兼容适配器单测:用替身 client 验证消息翻译 + usage 映射(不联网)。"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from types import SimpleNamespace
from typing import Any, cast
from openai import AsyncOpenAI
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter, _messages
from ww_llm_gateway.types import Block, LlmRequest, Scope
def _req(**kw: Any) -> LlmRequest:
kw.setdefault("tier", "writer")
return LlmRequest(scope=Scope(user_id=uuid.UUID(int=1)), **kw)
class _FakeStream:
def __init__(self, chunks: list[Any]) -> None:
self._chunks = chunks
async def __aiter__(self) -> AsyncIterator[Any]:
for c in self._chunks:
yield c
class _FakeCompletions:
def __init__(self, response: Any, stream_chunks: list[Any]) -> None:
self._response = response
self._stream_chunks = stream_chunks
self.last_messages: Any = None
async def create(self, **kw: Any) -> Any:
self.last_messages = kw.get("messages")
if kw.get("stream"):
return _FakeStream(self._stream_chunks)
return self._response
class _FakeClient:
def __init__(self, completions: _FakeCompletions) -> None:
self.chat = SimpleNamespace(completions=completions)
def test_messages_put_system_first_then_user() -> None:
req = _req(input="正文", system=[Block(text="世界观硬规则", cache=True)])
msgs = _messages(req)
assert msgs[0]["role"] == "system"
assert msgs[0]["content"] == "世界观硬规则"
assert msgs[1]["role"] == "user"
assert msgs[1]["content"] == "正文"
async def test_complete_maps_text_and_usage() -> None:
response = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="草稿"))],
usage=SimpleNamespace(
prompt_tokens=120,
completion_tokens=80,
prompt_tokens_details=SimpleNamespace(cached_tokens=30),
),
)
completions = _FakeCompletions(response, [])
adapter = OpenAICompatAdapter("deepseek", cast(AsyncOpenAI, _FakeClient(completions)))
result = await adapter.complete(_req(input="x"), "deepseek-chat")
assert result.text == "草稿"
assert result.usage.input_tokens == 120
assert result.usage.output_tokens == 80
assert result.usage.cache_read_tokens == 30
async def test_stream_yields_text_then_final_usage() -> None:
chunks = [
SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=""))], usage=None),
SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=""))], usage=None),
SimpleNamespace(
choices=[],
usage=SimpleNamespace(
prompt_tokens=10,
completion_tokens=2,
prompt_tokens_details=None,
),
),
]
completions = _FakeCompletions(None, chunks)
adapter = OpenAICompatAdapter("deepseek", cast(AsyncOpenAI, _FakeClient(completions)))
texts: list[str] = []
usage_seen = None
async for ch in adapter.stream(_req(input="x"), "deepseek-chat"):
if ch.text:
texts.append(ch.text)
if ch.usage is not None:
usage_seen = ch.usage
assert "".join(texts) == "他说"
assert usage_seen is not None
assert usage_seen.output_tokens == 2

View File

@@ -0,0 +1,123 @@
"""T2.1 结构化输出接线单测output_schema → instructor → LlmResponse.parsed。
注入 fake instructor client返回 (parsed, raw_completion)),不联网。
覆盖:① 带 schema → parsed 是实例且字段正确 + 仍记 1 条 ledger
② 无 schema → parsed is None 且纯文本路径不变。
"""
from __future__ import annotations
import uuid
from types import SimpleNamespace
from typing import Any, cast
from openai import AsyncOpenAI
from pydantic import BaseModel
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
from ww_llm_gateway.gateway import Gateway
from ww_llm_gateway.routing import Route
from ww_llm_gateway.types import LlmRequest, Scope, Tier
class _Out(BaseModel):
label: str
score: int
def _req(**kw: Any) -> LlmRequest:
kw.setdefault("tier", "analyst")
return LlmRequest(scope=Scope(user_id=uuid.UUID(int=1)), **kw)
def _route(_tier: Tier) -> Route:
return Route(provider="deepseek", model="deepseek-chat")
class _FakeStructured:
"""模拟 instructor.AsyncInstructorcreate_with_completion 返回 (parsed, raw)。"""
def __init__(self, parsed: BaseModel, raw: Any) -> None:
self._parsed = parsed
self._raw = raw
self.calls = 0
self.last_response_model: Any = None
async def create_with_completion(
self, *, messages: Any, response_model: Any, **kw: Any
) -> tuple[BaseModel, Any]:
self.calls += 1
self.last_response_model = response_model
return self._parsed, self._raw
def _raw_with_usage() -> Any:
return SimpleNamespace(
usage=SimpleNamespace(
prompt_tokens=42,
completion_tokens=7,
prompt_tokens_details=SimpleNamespace(cached_tokens=5),
)
)
def _client() -> AsyncOpenAI:
return cast(AsyncOpenAI, SimpleNamespace(chat=SimpleNamespace(completions=None)))
async def test_complete_with_schema_returns_parsed_instance() -> None:
structured = _FakeStructured(_Out(label="ok", score=9), _raw_with_usage())
adapter = OpenAICompatAdapter("deepseek", _client(), structured_client=structured)
result = await adapter.complete(_req(input="x", output_schema=_Out), "deepseek-chat")
assert isinstance(result.parsed, _Out)
assert result.parsed.label == "ok"
assert result.parsed.score == 9
assert structured.last_response_model is _Out
# usage 从 raw completion 提取
assert result.usage.input_tokens == 42
assert result.usage.output_tokens == 7
assert result.usage.cache_read_tokens == 5
async def test_complete_without_schema_keeps_parsed_none() -> None:
response = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="纯文本"))],
usage=SimpleNamespace(prompt_tokens=3, completion_tokens=2, prompt_tokens_details=None),
)
class _Completions:
async def create(self, **kw: Any) -> Any:
return response
client = cast(
AsyncOpenAI,
SimpleNamespace(chat=SimpleNamespace(completions=_Completions())),
)
adapter = OpenAICompatAdapter("deepseek", client)
result = await adapter.complete(_req(input="x"), "deepseek-chat")
assert result.parsed is None
assert result.text == "纯文本"
class _FakeLedger:
def __init__(self) -> None:
self.records: list[Any] = []
async def record(self, scope: Any, usage: Any) -> None:
self.records.append(usage)
async def test_gateway_run_passes_parsed_through_and_records_once() -> None:
structured = _FakeStructured(_Out(label="hit", score=1), _raw_with_usage())
adapter = OpenAICompatAdapter("deepseek", _client(), structured_client=structured)
ledger = _FakeLedger()
gw = Gateway({"deepseek": adapter}, ledger, resolver=_route)
resp = await gw.run(_req(input="x", output_schema=_Out))
assert isinstance(resp.parsed, _Out)
assert resp.parsed.label == "hit"
assert len(ledger.records) == 1

View File

@@ -0,0 +1,47 @@
"""LLM 网关C1 / ARCH §4薄自建tier→provider+model屏蔽厂商差异。"""
from __future__ import annotations
from .adapters.base import (
Capabilities,
ProviderAdapter,
ProviderResult,
ProviderUsage,
StreamChunk,
)
from .adapters.openai_compat import OpenAICompatAdapter
from .gateway import Gateway
from .ledger import LedgerSink, SqlAlchemyLedgerSink
from .routing import Route, resolve_route
from .types import (
Block,
Delta,
LlmRequest,
LlmResponse,
Scope,
ServedBy,
Tier,
Usage,
)
__all__ = [
"Block",
"Capabilities",
"Delta",
"Gateway",
"LedgerSink",
"LlmRequest",
"LlmResponse",
"OpenAICompatAdapter",
"ProviderAdapter",
"ProviderResult",
"ProviderUsage",
"Route",
"Scope",
"ServedBy",
"SqlAlchemyLedgerSink",
"StreamChunk",
"Tier",
"Usage",
"resolve_route",
]

View File

@@ -0,0 +1,51 @@
"""适配器接口与中间数据形ARCH §4.2/§4.4)。
适配器把 `LlmRequest` 翻译成目标厂商请求,并把响应/流/usage 翻译回统一中间形。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Protocol, runtime_checkable
from pydantic import BaseModel, ConfigDict
from ..types import LlmRequest
class Capabilities(BaseModel):
structured_output: bool = False
prefix_cache: bool = False
thinking: bool = False
class ProviderUsage(BaseModel):
input_tokens: int
output_tokens: int
cache_read_tokens: int = 0
class ProviderResult(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
text: str
usage: ProviderUsage
parsed: BaseModel | None = None # output_schema 命中时的结构化结果§4.4
class StreamChunk(BaseModel):
"""流式块文本增量usage=None或末尾用量块text="")。"""
text: str = ""
usage: ProviderUsage | None = None
@runtime_checkable
class ProviderAdapter(Protocol):
provider: str
def capabilities(self) -> Capabilities: ...
async def complete(self, req: LlmRequest, model: str) -> ProviderResult: ...
def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]: ...

View File

@@ -0,0 +1,129 @@
"""OpenAI 兼容适配器:一套覆盖 DeepSeek/Kimi/Qwen/GLM/OpenAIARCH §4.2)。
仅 base_url + model + key 不同。注入 `AsyncOpenAI` 客户端以便测试用替身。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any, Protocol
import instructor
from openai import AsyncOpenAI
from openai.types.chat import ChatCompletionMessageParam
from pydantic import BaseModel
from ..types import LlmRequest
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
class StructuredClient(Protocol):
"""instructor 风格的结构化客户端缝(`AsyncInstructor` 即满足此协议)。
抽成 Protocol 以便测试注入 fake绝不联网不变量测试零真实 LLM
"""
async def create_with_completion(
self, *, messages: Any, response_model: type[BaseModel], **kwargs: Any
) -> tuple[BaseModel, Any]: ...
def _system_text(req: LlmRequest) -> str:
return "\n\n".join(b.text for b in req.system)
def _input_text(req: LlmRequest) -> str:
if isinstance(req.input, str):
return req.input
return "\n\n".join(b.text for b in req.input)
def _messages(req: LlmRequest) -> list[ChatCompletionMessageParam]:
msgs: list[ChatCompletionMessageParam] = []
system = _system_text(req)
if system:
msgs.append({"role": "system", "content": system})
msgs.append({"role": "user", "content": _input_text(req)})
return msgs
def _cache_read(usage: Any) -> int:
details = getattr(usage, "prompt_tokens_details", None)
if details is None:
return 0
return int(getattr(details, "cached_tokens", 0) or 0)
def _usage_from(usage: Any) -> ProviderUsage:
if usage is None:
return ProviderUsage(input_tokens=0, output_tokens=0)
return ProviderUsage(
input_tokens=getattr(usage, "prompt_tokens", 0) or 0,
output_tokens=getattr(usage, "completion_tokens", 0) or 0,
cache_read_tokens=_cache_read(usage),
)
class OpenAICompatAdapter:
def __init__(
self,
provider: str,
client: AsyncOpenAI,
*,
structured_client: StructuredClient | None = None,
) -> None:
self.provider = provider
self._client = client
# 结构化输出走 instructorPydantic 校验 + 重试,锁定栈);可注入便于测试。
self._structured_client = structured_client
def capabilities(self) -> Capabilities:
return Capabilities(structured_output=True, prefix_cache=True, thinking=False)
def _structured(self) -> StructuredClient:
if self._structured_client is None:
# 懒构建:从同一 AsyncOpenAI client patch 出 instructor 客户端。
self._structured_client = instructor.from_openai(self._client)
return self._structured_client
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
if req.output_schema is not None:
return await self._complete_structured(req, model)
return await self._complete_text(req, model)
async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult:
resp = await self._client.chat.completions.create(
model=model,
messages=_messages(req),
max_tokens=req.max_tokens,
)
text = resp.choices[0].message.content or ""
return ProviderResult(text=text, usage=_usage_from(resp.usage))
async def _complete_structured(self, req: LlmRequest, model: str) -> ProviderResult:
assert req.output_schema is not None
parsed, raw = await self._structured().create_with_completion(
messages=_messages(req),
response_model=req.output_schema,
model=model,
max_tokens=req.max_tokens,
)
usage = _usage_from(getattr(raw, "usage", None))
# 文本载体保留校验后的 JSON便于日志/留痕);程序消费走 parsed。
return ProviderResult(text=parsed.model_dump_json(), usage=usage, parsed=parsed)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
stream = await self._client.chat.completions.create(
model=model,
messages=_messages(req),
max_tokens=req.max_tokens,
stream=True,
stream_options={"include_usage": True},
)
async for chunk in stream:
if chunk.choices:
delta = chunk.choices[0].delta
if delta and delta.content:
yield StreamChunk(text=delta.content)
if getattr(chunk, "usage", None):
yield StreamChunk(usage=_usage_from(chunk.usage))

View File

@@ -0,0 +1,99 @@
"""网关核心:路由 → 调用适配器 → 记账 → 返回ARCH §4.14.3/§4.8)。
M1 单 provider无回退/熔断(那是 M5/T5.4)。流式经 `stream()` 归一为 `Delta`。
日志脱敏:只记长度,绝不记原文/api key不变量、§9.3)。
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Callable
import structlog
from ww_shared import AppError, ErrorCode
from .adapters.base import ProviderAdapter, ProviderUsage
from .ledger import LedgerSink
from .pricing import cost_minor
from .routing import Route, resolve_route
from .types import Delta, LlmRequest, LlmResponse, ServedBy, Tier, Usage
log = structlog.get_logger(__name__)
def _input_len(req: LlmRequest) -> int:
if isinstance(req.input, str):
return len(req.input)
return sum(len(b.text) for b in req.input)
class Gateway:
def __init__(
self,
adapters: dict[str, ProviderAdapter],
ledger: LedgerSink,
resolver: Callable[[Tier], Route] = resolve_route,
) -> None:
self._adapters = adapters
self._ledger = ledger
self._resolve = resolver
def _adapter_for(self, provider: str) -> ProviderAdapter:
adapter = self._adapters.get(provider)
if adapter is None:
raise AppError(ErrorCode.LLM_UNAVAILABLE, f"no adapter for provider {provider!r}")
return adapter
def _usage(self, route: Route, pu: ProviderUsage) -> Usage:
cost, currency = cost_minor(route.provider, route.model, pu.input_tokens, pu.output_tokens)
return Usage(
provider=route.provider,
model=route.model,
input_tokens=pu.input_tokens,
output_tokens=pu.output_tokens,
cache_read_tokens=pu.cache_read_tokens,
cost_minor=cost,
currency=currency,
)
def _log_call(self, req: LlmRequest, usage: Usage, *, stream: bool) -> None:
log.info(
"llm_call",
provider=usage.provider,
model=usage.model,
tier=req.tier,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
cache_read_tokens=usage.cache_read_tokens,
cost_minor=usage.cost_minor,
currency=usage.currency,
stream=stream,
input_chars=_input_len(req),
project_id=str(req.scope.project_id) if req.scope.project_id else None,
)
async def run(self, req: LlmRequest) -> LlmResponse:
route = self._resolve(req.tier)
adapter = self._adapter_for(route.provider)
result = await adapter.complete(req, route.model)
usage = self._usage(route, result.usage)
await self._ledger.record(req.scope, usage)
self._log_call(req, usage, stream=False)
return LlmResponse(
text=result.text,
parsed=result.parsed,
usage=usage,
served_by=ServedBy(provider=route.provider, model=route.model),
)
async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]:
route = self._resolve(req.tier)
adapter = self._adapter_for(route.provider)
final = ProviderUsage(input_tokens=0, output_tokens=0)
async for chunk in adapter.stream(req, route.model):
if chunk.text:
yield Delta(text=chunk.text)
if chunk.usage is not None:
final = chunk.usage
usage = self._usage(route, final)
await self._ledger.record(req.scope, usage)
self._log_call(req, usage, stream=True)

View File

@@ -0,0 +1,39 @@
"""用量记账落库ARCH §4.8)。
`LedgerSink` 为接口,便于测试注入内存替身;生产用 SQLAlchemy 实现写 usage_ledger。
"""
from __future__ import annotations
from typing import Protocol
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import UsageLedger
from .types import Scope, Usage
class LedgerSink(Protocol):
async def record(self, scope: Scope, usage: Usage) -> None: ...
class SqlAlchemyLedgerSink:
"""把每次调用写入 usage_ledgerowner_id 取 scope.user_id单用户 stub"""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def record(self, scope: Scope, usage: Usage) -> None:
row = UsageLedger(
owner_id=scope.user_id,
project_id=scope.project_id,
provider=usage.provider,
model=usage.model,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
cache_read=usage.cache_read_tokens,
cost_minor=usage.cost_minor,
currency=usage.currency,
)
self._session.add(row)
await self._session.flush()

View File

@@ -0,0 +1,34 @@
"""提供商价格表与成本换算ARCH §4.8)。
价格以「每百万 token 的最小货币单位(如分/cent」表示随 provider 配置维护;
未知 (provider, model) 则成本计 0仍记账便于观测
"""
from __future__ import annotations
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class Price:
in_per_mtok: int
out_per_mtok: int
currency: str
# 近似价(可后续移入 config / provider 配置维护)
_PRICING: dict[tuple[str, str], Price] = {
("deepseek", "deepseek-chat"): Price(in_per_mtok=27, out_per_mtok=110, currency="USD"),
}
def cost_minor(provider: str, model: str, input_tokens: int, output_tokens: int) -> tuple[int, str]:
price = _PRICING.get((provider, model))
if price is None:
return 0, "USD"
cost = math.ceil(
input_tokens / 1_000_000 * price.in_per_mtok
+ output_tokens / 1_000_000 * price.out_per_mtok
)
return cost, price.currency

View File

@@ -0,0 +1,29 @@
"""档位路由tier -> (provider, model)ARCH §4.3)。
M1 只读全局默认config.tier_defaults形如 "deepseek:deepseek-chat"
作品级 / Skill 级覆盖留待后续§4.3 三级解析)。
"""
from __future__ import annotations
from dataclasses import dataclass
from ww_config import get_settings
from .types import Tier
@dataclass(frozen=True)
class Route:
provider: str
model: str
def resolve_route(tier: Tier) -> Route:
spec = get_settings().tier_defaults.get(tier)
if not spec:
raise ValueError(f"no tier_defaults entry for tier={tier!r}")
provider, sep, model = spec.partition(":")
if not sep or not provider or not model:
raise ValueError(f"invalid tier route {spec!r}; expected 'provider:model'")
return Route(provider=provider, model=model)

View File

@@ -0,0 +1,78 @@
"""LLM 网关统一接口契约C1 / ARCH §4.1——snake_casePydantic v2。
上层(编排器/Agent只碰这些类型永不接触具体厂商字段。Agent 只声明 `tier`
不传具体 model不变量 ②)。
"""
from __future__ import annotations
import uuid
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
Tier = Literal["writer", "analyst", "light"]
class Block(BaseModel):
"""一个 prompt 文本块;`cache=True` 标记缓存断点前的稳定块ARCH §4.6)。"""
text: str
cache: bool = False
class Scope(BaseModel):
"""调用作用域。原型单用户:`user_id` 可固定 stub`project_id` 可空。"""
user_id: uuid.UUID
project_id: uuid.UUID | None = None
class LlmRequest(BaseModel):
"""统一请求。`system` 稳定块在前(断点前),`input` 易变内容在后。"""
model_config = ConfigDict(arbitrary_types_allowed=True)
tier: Tier
input: str | list[Block]
system: list[Block] = Field(default_factory=list)
stream: bool = False
output_schema: type[BaseModel] | None = None
thinking: bool = False
max_tokens: int | None = None
scope: Scope
class Usage(BaseModel):
"""一次调用的用量与成本(落 usage_ledgerARCH §4.8)。"""
provider: str
model: str
input_tokens: int
output_tokens: int
cache_read_tokens: int = 0
cost_minor: int # 最小货币单位(如分/cent
currency: str
class ServedBy(BaseModel):
"""实际服务方;`fell_back` 标记是否走了回退链M5 才有回退M1 恒 False"""
provider: str
model: str
fell_back: bool = False
class LlmResponse(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
text: str
parsed: BaseModel | None = None
usage: Usage
served_by: ServedBy
class Delta(BaseModel):
"""流式增量:归一各家 SSE 的统一 token 块。"""
text: str

256
tests/test_m1_e2e.py Normal file
View File

@@ -0,0 +1,256 @@
"""M1 端到端:立项 → 写一章草稿(mock 网关) → 自动保存(真实 DB零 token
证明 M1 闭环:`POST /projects` → `GET /projects/{id}` → `POST .../draft`(SSE) →
`PUT .../draft`(自动保存),且 DB 为真源projects/chapters/usage_ledger 落库)。
确定性 & 零成本:网关用**真实 `Gateway`** 但注入一个吐固定 `Delta` 的假适配器
(不联网、不花 token其 `ledger` 是**真实 `SqlAlchemyLedgerSink`** —— 闭环含用量
记账也被走通。无 DB 时跳过(对齐 `tests/test_jobs_integration.py`)。
坑(见 memory/gotchas
- `ASGITransport` 不跑 lifespan → 用 `asgi-lifespan` 的 `LifespanManager` 触发
`seed_stub_user`,否则 `projects.owner_id`/`usage_ledger.owner_id` FK 报错。
- `get_sessionmaker` 缓存的 async engine 绑定首个事件循环;每个 DB 测试清缓存重建、
结束 dispose。
- 网关 `SqlAlchemyLedgerSink` 只 `flush()` 不 `commit()`(写库事务由编排层控制,
见不变量draft 端点在 SSE 流耗尽后对**请求 session** `commit()`usage_ledger
方落库。本测试用请求 session 做 ledger故无需手动提交——即对该提交的回归校验。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Annotated
import httpx
import pytest
from asgi_lifespan import LifespanManager
from fastapi import Depends
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ww_db import get_session, get_sessionmaker
from ww_db.models import Chapter, Project, UsageLedger
from ww_llm_gateway import (
Gateway,
SqlAlchemyLedgerSink,
resolve_route,
)
from ww_llm_gateway.adapters.base import (
Capabilities,
ProviderResult,
ProviderUsage,
StreamChunk,
)
from ww_llm_gateway.types import LlmRequest
# writer 档位的全局默认 providerconfig.tier_defaults["writer"] = "deepseek:...")。
_WRITER_PROVIDER = "deepseek"
# 确定性流式 token不联网、不花 token
_TOKENS = ["", "", "", "", "开端。"]
# 假适配器在末尾块回报的 token 数(喂记账,证明用量闭环走通)。
_FAKE_INPUT_TOKENS = 7
_FAKE_OUTPUT_TOKENS = 5
class _FakeStreamingAdapter:
"""实现 `ProviderAdapter` Protocol吐固定 `StreamChunk`,绝不联网。
末尾块带 `ProviderUsage` → 网关据此记账cost 经 pricing 表,未知 model→0
"""
provider = _WRITER_PROVIDER
def capabilities(self) -> Capabilities:
return Capabilities()
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
# M1 草稿走 streamcomplete 不参与本 E2E仍给确定性实现。
return ProviderResult(
text="".join(_TOKENS),
usage=ProviderUsage(input_tokens=_FAKE_INPUT_TOKENS, output_tokens=_FAKE_OUTPUT_TOKENS),
)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
for token in _TOKENS:
yield StreamChunk(text=token)
# 末尾用量块text=""):网关收尾时落 1 条 usage_ledger。
yield StreamChunk(
usage=ProviderUsage(input_tokens=_FAKE_INPUT_TOKENS, output_tokens=_FAKE_OUTPUT_TOKENS)
)
@pytest.fixture
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
"""真实 DB session 工厂;无 pg 时跳过(每测试清缓存重建 engine、结束 dispose"""
get_sessionmaker.cache_clear()
maker = get_sessionmaker()
try:
async with maker() as probe:
await probe.execute(select(1))
except Exception:
pytest.skip("postgres not reachable")
yield maker
await maker.kw["bind"].dispose()
get_sessionmaker.cache_clear()
def _parse_sse(raw: str) -> list[tuple[str, str]]:
"""把 text/event-stream 原文解析为 `(event, data)` 帧列表。"""
frames: list[tuple[str, str]] = []
event: str | None = None
data: str | None = None
for line in raw.splitlines():
if line.startswith("event:"):
event = line[len("event:") :].strip()
elif line.startswith("data:"):
data = line[len("data:") :].strip()
elif line == "":
if event is not None and data is not None:
frames.append((event, data))
event, data = None, None
if event is not None and data is not None:
frames.append((event, data))
return frames
async def test_m1_closed_loop_project_to_draft_to_autosave(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
import json
from ww_api.main import create_app
from ww_api.services.project_deps import get_writer_gateway
# 注入:真实 Gateway + 假适配器 + 真实 ledger。ledger 用**请求 session**
# `Depends(get_session)` 被 FastAPI 按请求缓存,与 draft 端点同一实例)——
# 故由端点流末的 `session.commit()` 落库,不在测试里手动提交。这正是对端点
# 提交修复的回归校验:若端点不提交,下面 usage_ledger 断言会失败。
def _override_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
return Gateway(
adapters={_WRITER_PROVIDER: _FakeStreamingAdapter()},
ledger=SqlAlchemyLedgerSink(session),
resolver=resolve_route,
)
app = create_app()
app.dependency_overrides[get_writer_gateway] = _override_gateway
transport = httpx.ASGITransport(app=app)
# LifespanManager 触发 lifespan → seed_stub_userowner_id FK 依赖它)。
async with LifespanManager(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
# 1) 立项 → 201。唯一标题避免跨次干扰。
title = "M1 E2E 闭环验证作品"
create_resp = await client.post(
"/projects",
json={
"title": title,
"genre": "玄幻",
"logline": "少年逆袭",
"selling_points": ["爽点密集"],
},
)
assert create_resp.status_code == 201
created = create_resp.json()
project_id = created["id"]
assert created["title"] == title
# 2) GET 详情 → 200与立项一致。
get_resp = await client.get(f"/projects/{project_id}")
assert get_resp.status_code == 200
fetched = get_resp.json()
assert fetched["id"] == project_id
assert fetched["title"] == title
assert fetched["genre"] == "玄幻"
assert fetched["selling_points"] == ["爽点密集"]
# 3) 流式写章草稿 → 消费 SSE≥1 token 帧 + 终结 done 帧,重组文本。
draft_resp = await client.post(f"/projects/{project_id}/chapters/1/draft")
assert draft_resp.status_code == 200
assert draft_resp.headers["content-type"].startswith("text/event-stream")
frames = _parse_sse(draft_resp.text)
token_frames = [d for (ev, d) in frames if ev == "token"]
done_frames = [d for (ev, d) in frames if ev == "done"]
error_frames = [d for (ev, d) in frames if ev == "error"]
assert len(token_frames) >= 1
assert len(done_frames) == 1
assert error_frames == []
streamed_text = "".join(json.loads(d)["text"] for d in token_frames)
assert streamed_text == "".join(_TOKENS)
# done 帧带累计长度。
assert json.loads(done_frames[0])["length"] == len(streamed_text)
# 不手动提交draft 端点流末 `session.commit()` 已把 usage_ledger 落库。
# 4) 自动保存草稿 ← 重组文本 → 200 DraftResponse{status:'draft', version:1}。
save_resp = await client.put(
f"/projects/{project_id}/chapters/1/draft",
json={"text": streamed_text},
)
assert save_resp.status_code == 200
saved = save_resp.json()
assert saved["project_id"] == project_id
assert saved["chapter_no"] == 1
assert saved["status"] == "draft"
assert saved["version"] == 1
assert saved["length"] == len(streamed_text)
# PUT 再次 → 幂等:不新增章节版本(覆盖同一行)。
save_resp2 = await client.put(
f"/projects/{project_id}/chapters/1/draft",
json={"text": streamed_text},
)
assert save_resp2.status_code == 200
assert save_resp2.json()["version"] == 1
# 5) DB 断言(经 e2e session 查询——DB 是唯一真源。
project_uuid = created["id"]
async with e2e_sm() as verify:
# projects 行存在且字段一致。
project_row = (
await verify.execute(select(Project).where(Project.id == project_uuid))
).scalar_one()
assert project_row.title == title
# chapters 草稿行status='draft', version=1, content=已保存文本。
chapter_rows = (
(await verify.execute(select(Chapter).where(Chapter.project_id == project_uuid)))
.scalars()
.all()
)
assert len(chapter_rows) == 1 # 幂等:两次 PUT 仍一行
chapter = chapter_rows[0]
assert chapter.chapter_no == 1
assert chapter.status == "draft"
assert chapter.version == 1
assert chapter.content == streamed_text
# usage_ledger草稿流至少写 1 条(用量记账闭环走通)。
ledger_count = (
await verify.execute(
select(func.count())
.select_from(UsageLedger)
.where(UsageLedger.project_id == project_uuid)
)
).scalar_one()
assert ledger_count >= 1
ledger_row = (
(
await verify.execute(
select(UsageLedger).where(UsageLedger.project_id == project_uuid)
)
)
.scalars()
.first()
)
assert ledger_row is not None
assert ledger_row.provider == _WRITER_PROVIDER
assert ledger_row.input_tokens == _FAKE_INPUT_TOKENS
assert ledger_row.output_tokens == _FAKE_OUTPUT_TOKENS
# 清理usage_ledger 的 project FK 无级联 → 先删它,再删 projectchapters 经 FK 级联)。
async with e2e_sm() as cleanup:
await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid))
await cleanup.execute(delete(Project).where(Project.id == project_uuid))
await cleanup.commit()