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

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