feat: M3 — 伏笔账本 + 节奏引擎 + 大纲(含并发记账 bugfix)
- 伏笔账本:纯函数状态机(OPEN/PARTIAL/CLOSED/OVERDUE) + ForeshadowLedger repo;验收后到期扫描(BackgroundTask 自建 session 置 OVERDUE);登记/状态变更端点 - 节奏 + 三审齐:foreshadow-analyst + pace-checker 并入 LangGraph 并行审(REVIEW_SPECS),collect 分列落 chapter_reviews(conflicts/foreshadow_sug/pace),review SSE 加 foreshadow/pace 事件 - 大纲:outliner Agent 产 OutlineResult(含 foreshadow_windows),POST /outline 逐章 upsert outline 表;GET /foreshadow?status= 看板 - 前端:伏笔四泳道看板(OVERDUE 琥珀) + 大纲编辑器(窗口徽标) + 节奏节拍图(▁▃▅) + 审稿页消费 foreshadow/pace SSE - bugfix(T3.8):并行三审共用请求 session 记账触发 'Session is already flushing' → foreshadow/pace 静默丢失;SqlAlchemyLedgerSink.record 改 add-only(靠端点/事务 commit),加并发回归测试 - M3 E2E:真实 DB + mock 网关零 token 走通 埋设→进展→验收后扫描 OVERDUE→看板 + 大纲含窗口 + 三审齐 SSE/留痕;E2E 暴露并钉住上述 bug - 门禁绿:mypy 111 / pytest 228(0 xfailed) / alembic 无漂移;前端 gen:api/lint/tsc/vitest 69/build
This commit is contained in:
@@ -77,6 +77,29 @@ class FakeRunGateway:
|
||||
)
|
||||
|
||||
|
||||
class SchemaRoutingRunGateway:
|
||||
"""按 `req.output_schema` 路由返对应 `parsed`——并行三审各拿自己 schema 的产物。
|
||||
|
||||
`by_schema`:{schema 类型: 该 schema 的实例}。命中即回该 parsed;未命中抛 KeyError
|
||||
(测试要保证三审 schema 都登记)。统计每个 schema 的调用次数。
|
||||
"""
|
||||
|
||||
def __init__(self, by_schema: dict[type[BaseModel], BaseModel]) -> None:
|
||||
self._by_schema = by_schema
|
||||
self.calls: list[type[BaseModel] | None] = []
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
schema = req.output_schema
|
||||
self.calls.append(schema)
|
||||
parsed = self._by_schema[schema] if schema is not None else None
|
||||
return LlmResponse(
|
||||
text=parsed.model_dump_json() if parsed is not None else "",
|
||||
parsed=parsed,
|
||||
usage=_stub_usage(),
|
||||
served_by=ServedBy(provider="fake", model="fake-analyst"),
|
||||
)
|
||||
|
||||
|
||||
class FailingRunGateway:
|
||||
"""`run` 抛指定异常——验「某审失败不阻塞其余」+ 未完成占位归一。"""
|
||||
|
||||
|
||||
293
packages/core/tests/test_foreshadow_repo.py
Normal file
293
packages/core/tests/test_foreshadow_repo.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""T3.1 伏笔账本写侧 repo 单测(ARCH §6.2)。
|
||||
|
||||
纯内存 fake,无 DB:断言 register 唯一、list_by_status 过滤、record_progress append
|
||||
不覆盖、transition 走状态机校验、scan_overdue 命中集合、按 project_id 隔离、frozen
|
||||
View 不可变。`(project_id, code)` 唯一性是 **DB 级**(models UniqueConstraint),纯 fake
|
||||
不引 pg 断言——真实 DB 路径由 T3.7 E2E 覆盖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from ww_core.domain.foreshadow_repo import ForeshadowLedgerRepo, ForeshadowLedgerView
|
||||
from ww_core.domain.foreshadow_state import (
|
||||
CLOSED,
|
||||
OVERDUE,
|
||||
PARTIAL,
|
||||
InvalidTransition,
|
||||
)
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
OTHER = uuid.UUID("00000000-0000-0000-0000-000000000002")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Row:
|
||||
project_id: uuid.UUID
|
||||
code: str
|
||||
title: str
|
||||
status: str
|
||||
planted_at: int | None
|
||||
content: str | None
|
||||
expected_close_from: int | None
|
||||
expected_close_to: int | None
|
||||
importance: str | None
|
||||
links: list[Any]
|
||||
progress: list[Any]
|
||||
|
||||
|
||||
def _view(row: _Row) -> ForeshadowLedgerView:
|
||||
return ForeshadowLedgerView(
|
||||
code=row.code,
|
||||
title=row.title,
|
||||
status=row.status,
|
||||
planted_at=row.planted_at,
|
||||
content=row.content,
|
||||
expected_close_from=row.expected_close_from,
|
||||
expected_close_to=row.expected_close_to,
|
||||
importance=row.importance,
|
||||
links=list(row.links),
|
||||
progress=list(row.progress),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeForeshadowLedgerRepo:
|
||||
"""内存替身——镜像 SqlForeshadowLedgerRepo 语义(按 (project_id, code) 唯一)。"""
|
||||
|
||||
rows: list[_Row] = field(default_factory=list)
|
||||
|
||||
def _find(self, project_id: uuid.UUID, code: str) -> _Row | None:
|
||||
return next((r for r in self.rows if r.project_id == project_id and r.code == code), None)
|
||||
|
||||
async def register(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
code: str,
|
||||
title: str,
|
||||
planted_at: int | None = None,
|
||||
content: str | None = None,
|
||||
expected_close_from: int | None = None,
|
||||
expected_close_to: int | None = None,
|
||||
importance: str | None = None,
|
||||
) -> ForeshadowLedgerView:
|
||||
if self._find(project_id, code) is not None:
|
||||
raise ValueError(f"duplicate foreshadow code: {code}")
|
||||
row = _Row(
|
||||
project_id=project_id,
|
||||
code=code,
|
||||
title=title,
|
||||
status="OPEN",
|
||||
planted_at=planted_at,
|
||||
content=content,
|
||||
expected_close_from=expected_close_from,
|
||||
expected_close_to=expected_close_to,
|
||||
importance=importance,
|
||||
links=[],
|
||||
progress=[],
|
||||
)
|
||||
self.rows.append(row)
|
||||
return _view(row)
|
||||
|
||||
async def get(self, project_id: uuid.UUID, code: str) -> ForeshadowLedgerView | None:
|
||||
row = self._find(project_id, code)
|
||||
return _view(row) if row is not None else None
|
||||
|
||||
async def list_by_status(
|
||||
self, project_id: uuid.UUID, status: str | None = None
|
||||
) -> list[ForeshadowLedgerView]:
|
||||
return [
|
||||
_view(r)
|
||||
for r in self.rows
|
||||
if r.project_id == project_id and (status is None or r.status == status)
|
||||
]
|
||||
|
||||
async def transition(
|
||||
self, project_id: uuid.UUID, code: str, *, to_status: str
|
||||
) -> ForeshadowLedgerView:
|
||||
from ww_core.domain.foreshadow_state import ForeshadowStatus
|
||||
from ww_core.domain.foreshadow_state import transition as apply_transition
|
||||
|
||||
row = self._find(project_id, code)
|
||||
if row is None:
|
||||
raise LookupError(f"foreshadow not found: {code}")
|
||||
row.status = apply_transition(ForeshadowStatus(row.status), ForeshadowStatus(to_status))
|
||||
return _view(row)
|
||||
|
||||
async def record_progress(
|
||||
self, project_id: uuid.UUID, code: str, *, entry: dict[str, Any]
|
||||
) -> ForeshadowLedgerView:
|
||||
row = self._find(project_id, code)
|
||||
if row is None:
|
||||
raise LookupError(f"foreshadow not found: {code}")
|
||||
row.progress = [*row.progress, dict(entry)]
|
||||
return _view(row)
|
||||
|
||||
async def scan_overdue(
|
||||
self, project_id: uuid.UUID, *, current_chapter: int
|
||||
) -> list[ForeshadowLedgerView]:
|
||||
from ww_core.domain.foreshadow_state import ForeshadowStatus, apply_overdue_scan
|
||||
|
||||
changed: list[ForeshadowLedgerView] = []
|
||||
for r in self.rows:
|
||||
if r.project_id != project_id:
|
||||
continue
|
||||
new = apply_overdue_scan(
|
||||
ForeshadowStatus(r.status),
|
||||
current_chapter=current_chapter,
|
||||
expected_close_to=r.expected_close_to,
|
||||
)
|
||||
if new != r.status:
|
||||
r.status = new
|
||||
changed.append(_view(r))
|
||||
return changed
|
||||
|
||||
|
||||
def _repo() -> FakeForeshadowLedgerRepo:
|
||||
return FakeForeshadowLedgerRepo()
|
||||
|
||||
|
||||
# ---- register ----
|
||||
|
||||
|
||||
async def test_register_starts_open() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
view = await repo.register(
|
||||
PROJECT, code="F1", title="神秘玉佩", planted_at=3, expected_close_to=20
|
||||
)
|
||||
assert isinstance(view, ForeshadowLedgerView)
|
||||
assert view.code == "F1"
|
||||
assert view.status == "OPEN"
|
||||
assert view.planted_at == 3
|
||||
assert view.expected_close_to == 20
|
||||
assert view.progress == []
|
||||
|
||||
|
||||
async def test_register_duplicate_code_rejected() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
await repo.register(PROJECT, code="F1", title="a")
|
||||
with pytest.raises(ValueError):
|
||||
await repo.register(PROJECT, code="F1", title="b")
|
||||
|
||||
|
||||
async def test_register_same_code_other_project_ok() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
await repo.register(PROJECT, code="F1", title="a")
|
||||
other = await repo.register(OTHER, code="F1", title="b")
|
||||
assert other.code == "F1"
|
||||
|
||||
|
||||
# ---- get / list_by_status ----
|
||||
|
||||
|
||||
async def test_get_returns_none_when_absent() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
assert await repo.get(PROJECT, "nope") is None
|
||||
|
||||
|
||||
async def test_list_by_status_filters() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
await repo.register(PROJECT, code="A", title="a")
|
||||
b = await repo.register(PROJECT, code="B", title="b")
|
||||
assert b.status == "OPEN"
|
||||
await repo.transition(PROJECT, "B", to_status=PARTIAL)
|
||||
|
||||
opens = await repo.list_by_status(PROJECT, "OPEN")
|
||||
assert {v.code for v in opens} == {"A"}
|
||||
partials = await repo.list_by_status(PROJECT, PARTIAL)
|
||||
assert {v.code for v in partials} == {"B"}
|
||||
|
||||
|
||||
async def test_list_by_status_none_returns_all() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
await repo.register(PROJECT, code="A", title="a")
|
||||
await repo.register(PROJECT, code="B", title="b")
|
||||
assert len(await repo.list_by_status(PROJECT)) == 2
|
||||
|
||||
|
||||
async def test_list_by_status_isolated_by_project() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
await repo.register(PROJECT, code="A", title="a")
|
||||
await repo.register(OTHER, code="B", title="b")
|
||||
rows = await repo.list_by_status(PROJECT)
|
||||
assert len(rows) == 1 and rows[0].code == "A"
|
||||
|
||||
|
||||
# ---- transition (走状态机) ----
|
||||
|
||||
|
||||
async def test_transition_applies_state_machine() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
await repo.register(PROJECT, code="A", title="a")
|
||||
v = await repo.transition(PROJECT, "A", to_status=PARTIAL)
|
||||
assert v.status == "PARTIAL"
|
||||
v2 = await repo.transition(PROJECT, "A", to_status=CLOSED)
|
||||
assert v2.status == "CLOSED"
|
||||
|
||||
|
||||
async def test_transition_rejects_illegal() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
await repo.register(PROJECT, code="A", title="a")
|
||||
await repo.transition(PROJECT, "A", to_status=CLOSED)
|
||||
with pytest.raises(InvalidTransition):
|
||||
await repo.transition(PROJECT, "A", to_status=PARTIAL)
|
||||
|
||||
|
||||
# ---- record_progress (append-only) ----
|
||||
|
||||
|
||||
async def test_record_progress_appends_not_overwrites() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
await repo.register(PROJECT, code="A", title="a")
|
||||
await repo.record_progress(PROJECT, "A", entry={"chapter": 5, "note": "first hint"})
|
||||
v = await repo.record_progress(PROJECT, "A", entry={"chapter": 9, "note": "second"})
|
||||
assert len(v.progress) == 2
|
||||
assert v.progress[0]["chapter"] == 5
|
||||
assert v.progress[1]["chapter"] == 9
|
||||
|
||||
|
||||
# ---- scan_overdue ----
|
||||
|
||||
|
||||
async def test_scan_overdue_marks_due_rows() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
await repo.register(PROJECT, code="DUE", title="d", expected_close_to=10)
|
||||
await repo.register(PROJECT, code="NOTYET", title="n", expected_close_to=30)
|
||||
await repo.register(PROJECT, code="NOWIN", title="w") # 无窗口
|
||||
changed = await repo.scan_overdue(PROJECT, current_chapter=15)
|
||||
assert {v.code for v in changed} == {"DUE"}
|
||||
assert all(v.status == "OVERDUE" for v in changed)
|
||||
# NOTYET / NOWIN 保持 OPEN
|
||||
assert (await repo.get(PROJECT, "NOTYET")).status == "OPEN" # type: ignore[union-attr]
|
||||
|
||||
|
||||
async def test_scan_overdue_skips_closed() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
await repo.register(PROJECT, code="C", title="c", expected_close_to=10)
|
||||
await repo.transition(PROJECT, "C", to_status=CLOSED)
|
||||
changed = await repo.scan_overdue(PROJECT, current_chapter=99)
|
||||
assert changed == []
|
||||
assert (await repo.get(PROJECT, "C")).status == "CLOSED" # type: ignore[union-attr]
|
||||
|
||||
|
||||
async def test_scan_overdue_isolated_by_project() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
await repo.register(OTHER, code="X", title="x", expected_close_to=5)
|
||||
changed = await repo.scan_overdue(PROJECT, current_chapter=99)
|
||||
assert changed == [] # OTHER 的行不受 PROJECT 扫描影响
|
||||
|
||||
|
||||
# ---- frozen View ----
|
||||
|
||||
|
||||
async def test_view_is_frozen() -> None:
|
||||
repo: ForeshadowLedgerRepo = _repo()
|
||||
v = await repo.register(PROJECT, code="A", title="a")
|
||||
with pytest.raises(ValidationError):
|
||||
v.status = OVERDUE
|
||||
143
packages/core/tests/test_foreshadow_state.py
Normal file
143
packages/core/tests/test_foreshadow_state.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""T3.1 伏笔状态机纯函数单测(ARCH §6.2)。
|
||||
|
||||
确定性纯函数,无 IO:覆盖全转移(登记/首次照应/完成回收)+ OVERDUE 到期判据边界
|
||||
(current ==/</> expected_close_to;CLOSED 不被覆盖;无 expected_close_to 不 OVERDUE)+
|
||||
非法转移显式处理。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from ww_core.domain.foreshadow_state import (
|
||||
CLOSED,
|
||||
OPEN,
|
||||
OVERDUE,
|
||||
PARTIAL,
|
||||
ForeshadowStatus,
|
||||
InvalidTransition,
|
||||
apply_overdue_scan,
|
||||
is_overdue,
|
||||
transition,
|
||||
)
|
||||
|
||||
# ---- 合法转移(登记→OPEN→PARTIAL→CLOSED)----
|
||||
|
||||
|
||||
def test_open_to_partial() -> None:
|
||||
assert transition(OPEN, PARTIAL) == PARTIAL
|
||||
|
||||
|
||||
def test_partial_to_closed() -> None:
|
||||
assert transition(PARTIAL, CLOSED) == CLOSED
|
||||
|
||||
|
||||
def test_open_to_closed_direct_recover() -> None:
|
||||
# 一步回收(埋后直接收)也合法
|
||||
assert transition(OPEN, CLOSED) == CLOSED
|
||||
|
||||
|
||||
def test_overdue_to_closed_recover_after_due() -> None:
|
||||
# 逾期后仍可回收
|
||||
assert transition(OVERDUE, CLOSED) == CLOSED
|
||||
|
||||
|
||||
def test_overdue_to_partial_partial_recover_after_due() -> None:
|
||||
assert transition(OVERDUE, PARTIAL) == PARTIAL
|
||||
|
||||
|
||||
# ---- 非法转移显式拒绝 ----
|
||||
|
||||
|
||||
def test_closed_cannot_reopen() -> None:
|
||||
with pytest.raises(InvalidTransition):
|
||||
transition(CLOSED, OPEN)
|
||||
|
||||
|
||||
def test_closed_cannot_become_overdue_via_transition() -> None:
|
||||
with pytest.raises(InvalidTransition):
|
||||
transition(CLOSED, OVERDUE)
|
||||
|
||||
|
||||
def test_closed_cannot_become_partial() -> None:
|
||||
with pytest.raises(InvalidTransition):
|
||||
transition(CLOSED, PARTIAL)
|
||||
|
||||
|
||||
def test_partial_cannot_go_back_to_open() -> None:
|
||||
with pytest.raises(InvalidTransition):
|
||||
transition(PARTIAL, OPEN)
|
||||
|
||||
|
||||
def test_same_status_is_noop_allowed() -> None:
|
||||
# 幂等:同态转移允许(看板手动设回同值不报错)
|
||||
assert transition(OPEN, OPEN) == OPEN
|
||||
assert transition(PARTIAL, PARTIAL) == PARTIAL
|
||||
|
||||
|
||||
# ---- is_overdue 判据边界 ----
|
||||
|
||||
|
||||
def test_is_overdue_strictly_past_window() -> None:
|
||||
assert is_overdue(current_chapter=11, expected_close_to=10, status=OPEN) is True
|
||||
|
||||
|
||||
def test_is_overdue_at_window_boundary_not_overdue() -> None:
|
||||
# current == expected_close_to:仍在窗口内,未逾期
|
||||
assert is_overdue(current_chapter=10, expected_close_to=10, status=OPEN) is False
|
||||
|
||||
|
||||
def test_is_overdue_before_window_not_overdue() -> None:
|
||||
assert is_overdue(current_chapter=5, expected_close_to=10, status=OPEN) is False
|
||||
|
||||
|
||||
def test_is_overdue_closed_never_overdue() -> None:
|
||||
assert is_overdue(current_chapter=99, expected_close_to=10, status=CLOSED) is False
|
||||
|
||||
|
||||
def test_is_overdue_partial_can_be_overdue() -> None:
|
||||
assert is_overdue(current_chapter=11, expected_close_to=10, status=PARTIAL) is True
|
||||
|
||||
|
||||
def test_is_overdue_without_window_never_overdue() -> None:
|
||||
# 无 expected_close_to → 永不逾期
|
||||
assert is_overdue(current_chapter=999, expected_close_to=None, status=OPEN) is False
|
||||
|
||||
|
||||
def test_is_overdue_already_overdue_stays() -> None:
|
||||
assert is_overdue(current_chapter=11, expected_close_to=10, status=OVERDUE) is True
|
||||
|
||||
|
||||
# ---- apply_overdue_scan ----
|
||||
|
||||
|
||||
def test_apply_overdue_scan_sets_overdue_when_due() -> None:
|
||||
new = apply_overdue_scan(OPEN, current_chapter=11, expected_close_to=10)
|
||||
assert new == OVERDUE
|
||||
|
||||
|
||||
def test_apply_overdue_scan_keeps_status_when_not_due() -> None:
|
||||
assert apply_overdue_scan(OPEN, current_chapter=10, expected_close_to=10) == OPEN
|
||||
|
||||
|
||||
def test_apply_overdue_scan_does_not_touch_closed() -> None:
|
||||
# CLOSED 不被 OVERDUE 覆盖(不变量)
|
||||
assert apply_overdue_scan(CLOSED, current_chapter=99, expected_close_to=10) == CLOSED
|
||||
|
||||
|
||||
def test_apply_overdue_scan_no_window_keeps_status() -> None:
|
||||
assert apply_overdue_scan(PARTIAL, current_chapter=99, expected_close_to=None) == PARTIAL
|
||||
|
||||
|
||||
def test_apply_overdue_scan_idempotent_on_overdue() -> None:
|
||||
assert apply_overdue_scan(OVERDUE, current_chapter=12, expected_close_to=10) == OVERDUE
|
||||
|
||||
|
||||
# ---- 枚举值与 DB 文本一致 ----
|
||||
|
||||
|
||||
def test_status_values_match_db_text() -> None:
|
||||
assert OPEN == "OPEN"
|
||||
assert PARTIAL == "PARTIAL"
|
||||
assert CLOSED == "CLOSED"
|
||||
assert OVERDUE == "OVERDUE"
|
||||
assert {s.value for s in ForeshadowStatus} == {"OPEN", "PARTIAL", "CLOSED", "OVERDUE"}
|
||||
137
packages/core/tests/test_outline_node.py
Normal file
137
packages/core/tests/test_outline_node.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""T3.4 大纲节点单测——build_outline_request + run_outline(C6 扩 / ARCH §5.4 §6.2)。
|
||||
|
||||
注入 mock 网关(产 `OutlineResult` parsed),无真 LLM、无真 Postgres。
|
||||
大纲节点是裸函数(独立生成,不在写章/审稿流水线);只产结构化大纲,**不写库**
|
||||
(持久化属 T3.5 验收/端点,不变量 #3)。asyncio_mode=auto。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fakes_orchestrator import FailingRunGateway, FakeRunGateway
|
||||
from ww_agents import (
|
||||
ForeshadowWindow,
|
||||
OutlineChapter,
|
||||
OutlineResult,
|
||||
outliner_spec,
|
||||
)
|
||||
from ww_core.orchestrator import build_outline_request, run_outline
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
CONTEXT = "## 立意\n复仇成长\n## 已开伏笔\nFS-BIRTH: 身世之谜(待第20-30章回收)"
|
||||
|
||||
_OUTLINE = OutlineResult(
|
||||
chapters=[
|
||||
OutlineChapter(
|
||||
no=1,
|
||||
beats=["主角登场", "埋身世伏笔"],
|
||||
foreshadow_windows=[
|
||||
ForeshadowWindow(
|
||||
code="FS-BIRTH",
|
||||
plant_chapter=1,
|
||||
expected_close_from=20,
|
||||
expected_close_to=30,
|
||||
)
|
||||
],
|
||||
),
|
||||
OutlineChapter(no=2, beats=["遭遇对手"], foreshadow_windows=[]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---- build_outline_request:tier 不变量、缓存断点、output_schema、run 非 stream ----
|
||||
|
||||
|
||||
def test_build_outline_request_uses_spec_tier_and_cached_prompt() -> None:
|
||||
req = build_outline_request(outliner_spec, context=CONTEXT, user_id=USER, project_id=PROJECT)
|
||||
|
||||
assert req.tier == "analyst" # 不变量②:spec 档位透传,不传 model
|
||||
assert req.stream is False # 排大纲用 run() 非 stream()
|
||||
assert len(req.system) == 1
|
||||
assert req.system[0].text == outliner_spec.system_prompt
|
||||
assert req.system[0].cache is True # 不变量#9
|
||||
assert req.input == CONTEXT
|
||||
assert req.output_schema is OutlineResult
|
||||
assert req.scope.user_id == USER
|
||||
assert req.scope.project_id == PROJECT
|
||||
|
||||
|
||||
# ---- run_outline:返回结构化大纲含 foreshadow_windows,只读不写库 ----
|
||||
|
||||
|
||||
async def test_run_outline_returns_structured_outline() -> None:
|
||||
gateway = FakeRunGateway(_OUTLINE)
|
||||
|
||||
result = await run_outline(
|
||||
outliner_spec, context=CONTEXT, gateway=gateway, user_id=USER, project_id=PROJECT
|
||||
)
|
||||
|
||||
assert isinstance(result, OutlineResult)
|
||||
assert len(result.chapters) == 2
|
||||
assert result.chapters[0].no == 1
|
||||
window = result.chapters[0].foreshadow_windows[0]
|
||||
assert window.code == "FS-BIRTH"
|
||||
assert window.expected_close_to == 30
|
||||
assert gateway.call_count == 1
|
||||
|
||||
|
||||
async def test_run_outline_forwards_constructed_request() -> None:
|
||||
gateway = FakeRunGateway(_OUTLINE)
|
||||
|
||||
await run_outline(
|
||||
outliner_spec, context=CONTEXT, gateway=gateway, user_id=USER, project_id=PROJECT
|
||||
)
|
||||
|
||||
req = gateway.last_request
|
||||
assert req is not None
|
||||
assert req.tier == "analyst"
|
||||
assert req.output_schema is OutlineResult
|
||||
assert req.input == CONTEXT
|
||||
|
||||
|
||||
async def test_run_outline_raises_when_gateway_fails() -> None:
|
||||
# 大纲是独立生成(非流水线并行审);网关失败直接上抛,端点/T3.5 处理。
|
||||
gateway = FailingRunGateway(RuntimeError("provider down"))
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await run_outline(
|
||||
outliner_spec,
|
||||
context=CONTEXT,
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
|
||||
async def test_run_outline_raises_when_parsed_missing() -> None:
|
||||
# 带 output_schema 时 parsed 必非 None(C1);若网关违约返回 None,明确报错而非静默。
|
||||
class _NoParseGateway:
|
||||
async def run(self, req: object) -> object:
|
||||
from ww_llm_gateway.types import LlmResponse, ServedBy, Usage
|
||||
|
||||
return LlmResponse(
|
||||
text="{}",
|
||||
parsed=None,
|
||||
usage=Usage(
|
||||
provider="fake",
|
||||
model="fake-analyst",
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
cost_minor=0,
|
||||
currency="USD",
|
||||
),
|
||||
served_by=ServedBy(provider="fake", model="fake-analyst"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="parsed"):
|
||||
await run_outline(
|
||||
outliner_spec,
|
||||
context=CONTEXT,
|
||||
gateway=_NoParseGateway(), # type: ignore[arg-type]
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
125
packages/core/tests/test_outline_write_repo.py
Normal file
125
packages/core/tests/test_outline_write_repo.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""T3.5 大纲写侧 repo 单测(ARCH §5.4 / §7.2)。
|
||||
|
||||
纯内存 fake,无 DB:断言 upsert 幂等覆盖、按 project_id 隔离、frozen View 不可变、
|
||||
`_to_view` 的 beats 包裹/解包 round-trip。`(project_id, chapter_no)` 唯一性是 **DB 级**
|
||||
(models UniqueConstraint),纯 fake 不引 pg 断言——真实 DB 路径由 T3.7 E2E 覆盖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from ww_core.domain.outline_write_repo import (
|
||||
OutlineWriteRepo,
|
||||
OutlineWriteView,
|
||||
_to_view,
|
||||
)
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
OTHER = uuid.UUID("00000000-0000-0000-0000-000000000002")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Row:
|
||||
"""模拟 ORM Outline 行(beats 是 JSONB dict、foreshadow_windows 是 JSONB list)。"""
|
||||
|
||||
project_id: uuid.UUID
|
||||
volume: int
|
||||
chapter_no: int
|
||||
beats: dict[str, Any] | None
|
||||
foreshadow_windows: list[Any] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeOutlineWriteRepo:
|
||||
"""实现 `OutlineWriteRepo` Protocol(内存;幂等覆盖同 (project_id, chapter_no))。"""
|
||||
|
||||
rows: dict[tuple[uuid.UUID, int], _Row] = field(default_factory=dict)
|
||||
|
||||
async def upsert_chapter(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
volume: int,
|
||||
chapter_no: int,
|
||||
beats: list[str],
|
||||
foreshadow_windows: list[dict[str, Any]],
|
||||
) -> OutlineWriteView:
|
||||
row = self.rows.get((project_id, chapter_no))
|
||||
beats_payload = {"beats": list(beats)}
|
||||
windows = [dict(w) for w in foreshadow_windows]
|
||||
if row is None:
|
||||
row = _Row(
|
||||
project_id=project_id,
|
||||
volume=volume,
|
||||
chapter_no=chapter_no,
|
||||
beats=beats_payload,
|
||||
foreshadow_windows=windows,
|
||||
)
|
||||
else:
|
||||
row.volume = volume
|
||||
row.beats = beats_payload
|
||||
row.foreshadow_windows = windows
|
||||
self.rows[(project_id, chapter_no)] = row
|
||||
return _to_view(row) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _repo() -> OutlineWriteRepo:
|
||||
return FakeOutlineWriteRepo()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_inserts_new_chapter() -> None:
|
||||
repo = _repo()
|
||||
view = await repo.upsert_chapter(
|
||||
PROJECT,
|
||||
volume=1,
|
||||
chapter_no=1,
|
||||
beats=["a", "b"],
|
||||
foreshadow_windows=[{"code": "F1", "expected_close_to": 10}],
|
||||
)
|
||||
assert view.chapter_no == 1
|
||||
assert view.volume == 1
|
||||
assert view.beats == ["a", "b"]
|
||||
assert view.foreshadow_windows[0]["code"] == "F1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_overwrites_same_chapter() -> None:
|
||||
repo = _repo()
|
||||
await repo.upsert_chapter(PROJECT, volume=1, chapter_no=1, beats=["old"], foreshadow_windows=[])
|
||||
view = await repo.upsert_chapter(
|
||||
PROJECT, volume=2, chapter_no=1, beats=["new"], foreshadow_windows=[]
|
||||
)
|
||||
assert view.beats == ["new"]
|
||||
assert view.volume == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_isolated_by_project() -> None:
|
||||
repo = FakeOutlineWriteRepo()
|
||||
await repo.upsert_chapter(PROJECT, volume=1, chapter_no=1, beats=["p"], foreshadow_windows=[])
|
||||
await repo.upsert_chapter(OTHER, volume=1, chapter_no=1, beats=["o"], foreshadow_windows=[])
|
||||
assert len(repo.rows) == 2
|
||||
|
||||
|
||||
def test_to_view_unpacks_wrapped_beats() -> None:
|
||||
row = _Row(project_id=PROJECT, volume=1, chapter_no=3, beats={"beats": ["x", "y"]})
|
||||
view = _to_view(row) # type: ignore[arg-type]
|
||||
assert view.beats == ["x", "y"]
|
||||
|
||||
|
||||
def test_to_view_handles_null_beats() -> None:
|
||||
row = _Row(project_id=PROJECT, volume=1, chapter_no=3, beats=None)
|
||||
view = _to_view(row) # type: ignore[arg-type]
|
||||
assert view.beats == []
|
||||
|
||||
|
||||
def test_write_view_is_frozen() -> None:
|
||||
view = OutlineWriteView(chapter_no=1, volume=1, beats=["a"])
|
||||
with pytest.raises(ValidationError):
|
||||
view.volume = 2
|
||||
@@ -13,16 +13,31 @@ from fakes_orchestrator import (
|
||||
FailingRunGateway,
|
||||
FakeReviewRepo,
|
||||
FakeRunGateway,
|
||||
SchemaRoutingRunGateway,
|
||||
)
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from ww_agents import Conflict, ContinuityReview, continuity_spec
|
||||
from ww_agents import (
|
||||
Conflict,
|
||||
ContinuityReview,
|
||||
ForeshadowReview,
|
||||
ForeshadowSuggestion,
|
||||
PaceIssue,
|
||||
PaceReview,
|
||||
continuity_spec,
|
||||
foreshadow_spec,
|
||||
pace_spec,
|
||||
)
|
||||
from ww_core.orchestrator import (
|
||||
CONTINUITY,
|
||||
EVENT_CONFLICT,
|
||||
EVENT_DONE,
|
||||
EVENT_ERROR,
|
||||
EVENT_FORESHADOW,
|
||||
EVENT_PACE,
|
||||
EVENT_SECTION,
|
||||
FORESHADOW,
|
||||
PACE,
|
||||
REVIEW_INCOMPLETE,
|
||||
REVIEW_OK,
|
||||
SECTION_DONE,
|
||||
@@ -33,6 +48,8 @@ from ww_core.orchestrator import (
|
||||
build_review_request,
|
||||
collect_reviews,
|
||||
extract_conflicts,
|
||||
extract_foreshadow_sug,
|
||||
extract_pace,
|
||||
make_review_node,
|
||||
normalize_review,
|
||||
run_review,
|
||||
@@ -242,3 +259,177 @@ async def test_normalize_review_maps_unexpected_error_to_error_event() -> None:
|
||||
assert events[-1].event == EVENT_ERROR
|
||||
assert events[-1].data["code"] == ErrorCode.INTERNAL
|
||||
assert events[-1].data["request_id"] == "req-9"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# T3.3 三审齐:foreshadow + pace 并入并行审 / collect 列映射 / SSE surface
|
||||
# ============================================================================
|
||||
|
||||
_FORESHADOW = ForeshadowReview(
|
||||
planted=[
|
||||
ForeshadowSuggestion(code="FS-MARK", title="胸口胎记", where="第2段", note="皇族暗示")
|
||||
],
|
||||
resolved=[ForeshadowSuggestion(code="FS-BIRTH", title="身世", where="第9段", note="回收")],
|
||||
)
|
||||
_PACE = PaceReview(
|
||||
water=[PaceIssue(where="第4-6段", reason="信息密度低")],
|
||||
hook=True,
|
||||
beat_map=[1, 3, 5, 0, 4],
|
||||
)
|
||||
|
||||
|
||||
def _three_reviews() -> dict[str, Any]:
|
||||
"""三审皆 ok 的 state['reviews'](continuity/foreshadow/pace)。"""
|
||||
return {
|
||||
CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}},
|
||||
FORESHADOW: {"status": REVIEW_OK, "result": _FORESHADOW.model_dump()},
|
||||
PACE: {"status": REVIEW_OK, "result": _PACE.model_dump()},
|
||||
}
|
||||
|
||||
|
||||
# ---- collect 三审列映射 ----
|
||||
|
||||
|
||||
def test_extract_foreshadow_sug_flattens_planted_and_resolved() -> None:
|
||||
sug = extract_foreshadow_sug(_three_reviews())
|
||||
|
||||
assert len(sug) == 2 # 1 planted + 1 resolved 扁平
|
||||
kinds = {s["kind"] for s in sug}
|
||||
assert kinds == {"planted", "resolved"}
|
||||
planted = next(s for s in sug if s["kind"] == "planted")
|
||||
assert planted["code"] == "FS-MARK"
|
||||
|
||||
|
||||
def test_extract_pace_returns_dict() -> None:
|
||||
pace = extract_pace(_three_reviews())
|
||||
|
||||
assert pace is not None
|
||||
assert pace["hook"] is True
|
||||
assert pace["beat_map"] == [1, 3, 5, 0, 4]
|
||||
|
||||
|
||||
def test_extract_foreshadow_and_pace_empty_when_incomplete() -> None:
|
||||
reviews = {
|
||||
FORESHADOW: {"status": REVIEW_INCOMPLETE, "result": None},
|
||||
PACE: {"status": REVIEW_INCOMPLETE, "result": None},
|
||||
}
|
||||
assert extract_foreshadow_sug(reviews) == []
|
||||
assert extract_pace(reviews) is None
|
||||
|
||||
|
||||
async def test_collect_records_three_audits_to_their_columns() -> None:
|
||||
repo = FakeReviewRepo()
|
||||
|
||||
await collect_reviews(_state(_three_reviews()), review_repo=repo)
|
||||
|
||||
assert len(repo.records) == 1 # 一次 record 落齐三审
|
||||
rec = repo.records[0]
|
||||
assert rec["conflicts"][0]["type"] == "能力不符" # continuity→conflicts
|
||||
assert len(rec["foreshadow_sug"]) == 2 # foreshadow→foreshadow_sug(扁平)
|
||||
assert rec["pace"]["hook"] is True # pace→pace
|
||||
|
||||
|
||||
# ---- 三审并行图跑通(mock 网关按 schema 路由产三 parsed)----
|
||||
|
||||
|
||||
async def test_three_review_graph_runs_all_audits_end_to_end() -> None:
|
||||
gateway = SchemaRoutingRunGateway(
|
||||
{
|
||||
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
|
||||
ForeshadowReview: _FORESHADOW,
|
||||
PaceReview: _PACE,
|
||||
}
|
||||
)
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(
|
||||
gateway,
|
||||
repo,
|
||||
review_specs=(continuity_spec, foreshadow_spec, pace_spec),
|
||||
checkpointer=MemorySaver(),
|
||||
)
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "rev-3"}}
|
||||
|
||||
final = await graph.ainvoke(_state(), config=config)
|
||||
|
||||
assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
|
||||
assert final["reviews"][FORESHADOW]["status"] == REVIEW_OK
|
||||
assert final["reviews"][PACE]["status"] == REVIEW_OK
|
||||
assert len(gateway.calls) == 3 # 三审各调一次网关
|
||||
# collect 一次 record 落齐三列
|
||||
rec = repo.records[0]
|
||||
assert rec["conflicts"] and rec["foreshadow_sug"] and rec["pace"]
|
||||
|
||||
|
||||
async def test_three_review_graph_isolates_one_failing_audit() -> None:
|
||||
# foreshadow schema 缺失 → SchemaRoutingRunGateway 抛 KeyError → run_review 隔离为 incomplete
|
||||
gateway = SchemaRoutingRunGateway(
|
||||
{
|
||||
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
|
||||
PaceReview: _PACE,
|
||||
}
|
||||
)
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(
|
||||
gateway,
|
||||
repo,
|
||||
review_specs=(continuity_spec, foreshadow_spec, pace_spec),
|
||||
checkpointer=MemorySaver(),
|
||||
)
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "rev-4"}}
|
||||
|
||||
final = await graph.ainvoke(_state(), config=config)
|
||||
|
||||
# foreshadow 失败被隔离,其余两审照常 + collect 照常留痕(foreshadow_sug 空)
|
||||
assert final["reviews"][FORESHADOW]["status"] == REVIEW_INCOMPLETE
|
||||
assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
|
||||
assert final["reviews"][PACE]["status"] == REVIEW_OK
|
||||
rec = repo.records[0]
|
||||
assert rec["conflicts"] # continuity 落库
|
||||
assert rec["foreshadow_sug"] == [] # 失败列留空,不阻塞其余
|
||||
assert rec["pace"]["hook"] is True # pace 落库
|
||||
|
||||
|
||||
# ---- SSE:normalize_review surface 三审(section + conflict + foreshadow + pace + done)----
|
||||
|
||||
|
||||
async def test_normalize_review_surfaces_three_audits() -> None:
|
||||
events = [e async for e in normalize_review(_three_reviews())]
|
||||
kinds = [e.event for e in events]
|
||||
|
||||
# 字典序遍历:continuity < foreshadow < pace
|
||||
# continuity: section + 1 conflict
|
||||
# foreshadow: section + 2(planted+resolved);pace: section + 1
|
||||
assert kinds == [
|
||||
EVENT_SECTION,
|
||||
EVENT_CONFLICT,
|
||||
EVENT_SECTION,
|
||||
EVENT_FORESHADOW,
|
||||
EVENT_FORESHADOW,
|
||||
EVENT_SECTION,
|
||||
EVENT_PACE,
|
||||
EVENT_DONE,
|
||||
]
|
||||
# foreshadow 事件带 kind + code
|
||||
fs_events = [e for e in events if e.event == EVENT_FORESHADOW]
|
||||
assert {e.data["kind"] for e in fs_events} == {"planted", "resolved"}
|
||||
# pace 事件带节拍图
|
||||
pace_evt = next(e for e in events if e.event == EVENT_PACE)
|
||||
assert pace_evt.data["beat_map"] == [1, 3, 5, 0, 4]
|
||||
assert pace_evt.data["hook"] is True
|
||||
assert events[-1].data == {"length": 3} # 三审项
|
||||
|
||||
|
||||
async def test_normalize_review_skips_results_for_incomplete_audit() -> None:
|
||||
reviews = {
|
||||
CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": []}},
|
||||
FORESHADOW: {"status": REVIEW_INCOMPLETE, "result": None},
|
||||
PACE: {"status": REVIEW_OK, "result": _PACE.model_dump()},
|
||||
}
|
||||
|
||||
events = [e async for e in normalize_review(reviews)]
|
||||
kinds = [e.event for e in events]
|
||||
|
||||
# foreshadow incomplete → 仅 section,无 foreshadow 事件
|
||||
assert kinds == [EVENT_SECTION, EVENT_SECTION, EVENT_SECTION, EVENT_PACE, EVENT_DONE]
|
||||
fs_section = next(e for e in events if e.data.get("name") == FORESHADOW)
|
||||
assert fs_section.data["status"] == SECTION_INCOMPLETE
|
||||
|
||||
Reference in New Issue
Block a user