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:
2
packages/core/README.md
Normal file
2
packages/core/README.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# core (@llm/@backend) — 占位骨架
|
||||
领域核心(domain/状态机)、编排器(orchestrator/LangGraph 图)、记忆服务(memory)。Phase 1+ 由对应 owner 填充。
|
||||
26
packages/core/pyproject.toml
Normal file
26
packages/core/pyproject.toml
Normal 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 }
|
||||
122
packages/core/tests/fakes_orchestrator.py
Normal file
122
packages/core/tests/fakes_orchestrator.py
Normal 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
|
||||
270
packages/core/tests/test_memory.py
Normal file
270
packages/core/tests/test_memory.py
Normal 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="全局规则")],
|
||||
)
|
||||
165
packages/core/tests/test_orchestrator.py
Normal file
165
packages/core/tests/test_orchestrator.py
Normal 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
|
||||
|
||||
|
||||
# ---- 图编译:装节点 + checkpointer(MemorySaver,不连真 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"] == "云深"
|
||||
0
packages/core/ww_core/__init__.py
Normal file
0
packages/core/ww_core/__init__.py
Normal file
189
packages/core/ww_core/domain/chapter_repo.py
Normal file
189
packages/core/ww_core/domain/chapter_repo.py
Normal 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)
|
||||
107
packages/core/ww_core/domain/project_repo.py
Normal file
107
packages/core/ww_core/domain/project_repo.py
Normal 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)
|
||||
132
packages/core/ww_core/domain/repositories.py
Normal file
132
packages/core/ww_core/domain/repositories.py
Normal 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_case,frozen 防止意外突变)----
|
||||
|
||||
|
||||
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
|
||||
28
packages/core/ww_core/memory/__init__.py
Normal file
28
packages/core/ww_core/memory/__init__.py
Normal 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",
|
||||
]
|
||||
129
packages/core/ww_core/memory/assemble.py
Normal file
129
packages/core/ww_core/memory/assemble.py
Normal 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)
|
||||
62
packages/core/ww_core/memory/render.py
Normal file
62
packages/core/ww_core/memory/render.py
Normal 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)
|
||||
112
packages/core/ww_core/memory/selection.py
Normal file
112
packages/core/ww_core/memory/selection.py
Normal 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 默认 3–5)
|
||||
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)
|
||||
190
packages/core/ww_core/memory/sql_repositories.py
Normal file
190
packages/core/ww_core/memory/sql_repositories.py
Normal 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 repo(T1.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),
|
||||
)
|
||||
60
packages/core/ww_core/memory/types.py
Normal file
60
packages/core/ww_core/memory/types.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""记忆服务输出契约(C5)——AssembledContext / SelectionTrace。
|
||||
|
||||
决策(memory/decisions.md 2026-06-18):assemble 返回中性序列化文本,
|
||||
不返回 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
|
||||
97
packages/core/ww_core/orchestrator/__init__.py
Normal file
97
packages/core/ww_core/orchestrator/__init__.py
Normal 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",
|
||||
]
|
||||
100
packages/core/ww_core/orchestrator/graph.py
Normal file
100
packages/core/ww_core/orchestrator/graph.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""写章图工厂 + Postgres checkpointer(C4 / 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()
|
||||
167
packages/core/ww_core/orchestrator/sse.py
Normal file
167
packages/core/ww_core/orchestrator/sse.py
Normal 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.4(apps/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_case);T1.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)
|
||||
48
packages/core/ww_core/orchestrator/state.py
Normal file
48
packages/core/ww_core/orchestrator/state.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""编排器图状态(C4 / ARCH §5.2)——只存**控制流 + 组装上下文 + 累积草稿**。
|
||||
|
||||
不变量 #5:checkpoint 只承载控制流位置 + 组装上下文 + 已生成草稿增量 + 过程态审稿产物,
|
||||
正文/审稿以领域表为权威;resume 时从 `chapters`/`chapter_reviews` 重读(M2)。
|
||||
M2 加并行审 + collect;验收/interrupt 的真相源重建归 T2.4(accept 不在本图)。
|
||||
|
||||
`ChapterState` 用 TypedDict(LangGraph 原生状态形态),字段全 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`(M2):collect 汇总的各审分项结果(**过程态**)。落 `chapter_reviews`
|
||||
表后真相在表;不变量 #5:checkpoint 不作审稿真相源,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]
|
||||
89
packages/core/ww_core/orchestrator/write_node.py
Normal file
89
packages/core/ww_core/orchestrator/write_node.py
Normal 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)}
|
||||
Reference in New Issue
Block a user