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:
Yaojia Wang
2026-06-18 14:21:17 +02:00
parent 68f194a043
commit 5fb7bfb1de
74 changed files with 6529 additions and 126 deletions

View File

@@ -9,6 +9,27 @@ from ww_core.domain.chapter_repo import (
SqlChapterRepo,
)
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
from ww_core.domain.foreshadow_repo import (
ForeshadowLedgerRepo,
ForeshadowLedgerView,
SqlForeshadowLedgerRepo,
)
from ww_core.domain.foreshadow_state import (
CLOSED,
OPEN,
OVERDUE,
PARTIAL,
ForeshadowStatus,
InvalidTransition,
apply_overdue_scan,
is_overdue,
transition,
)
from ww_core.domain.outline_write_repo import (
OutlineWriteRepo,
OutlineWriteView,
SqlOutlineWriteRepo,
)
from ww_core.domain.project_repo import (
ProjectCreate,
ProjectRepo,
@@ -26,7 +47,22 @@ __all__ = [
"DigestAppendRepo",
"SqlDigestAppendRepo",
"DigestView",
"ForeshadowLedgerRepo",
"ForeshadowLedgerView",
"SqlForeshadowLedgerRepo",
"ForeshadowStatus",
"InvalidTransition",
"OPEN",
"PARTIAL",
"CLOSED",
"OVERDUE",
"apply_overdue_scan",
"is_overdue",
"transition",
"MemoryRepos",
"OutlineWriteRepo",
"OutlineWriteView",
"SqlOutlineWriteRepo",
"ProjectCreate",
"ProjectRepo",
"ProjectView",

View File

@@ -0,0 +1,219 @@
"""伏笔账本**写侧** Repository登记/状态机/进展/到期扫描ARCH §6.2 / PRODUCT_SPEC §4.2)。
读侧(`list_for_codes`,供 assemble 注入伏笔窗口)已在 `ww_core.memory.sql_repositories`
+ `domain.repositories.ForeshadowRepo`/`ForeshadowView` 提供(最小 ViewC5 稳定,不动)。
本模块加**写**能力 + 看板查询,命名加 `Ledger` 前缀避免与读侧同名歧义
(同 T2.3 `DigestAppendRepo` 区别于读侧 `DigestRepo` 的先例,见 memory/decisions
`ForeshadowLedgerView` 比读侧 View 多 `importance/links/progress`(看板/账本需要)。
**提交边界**:所有写方法**只 `flush()` 不 `commit()`**——提交交调用方:
- T3.2 验收后扫描挂 BackgroundTask / 接 §5.5 `TODO(M3)`,由那条事务/任务提交;
- T3.5 登记/状态端点由端点事务提交。
与 M2 验收-side repos 一致(写库副作用归编排/事务层,见 memory/gotchas
含 nullable 列的唯一约束 `(project_id, code)` 不用 PG `ON CONFLICT`——`register` 仅
显式 INSERT重复 code 让 DB 唯一约束/调用方处理),不做 upsert见 gotcha
"""
from __future__ import annotations
import uuid
from typing import Any, Protocol
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Foreshadow
from ww_core.domain.foreshadow_state import (
ForeshadowStatus,
apply_overdue_scan,
)
from ww_core.domain.foreshadow_state import (
transition as apply_transition,
)
class ForeshadowLedgerView(BaseModel):
"""伏笔账本只读快照snake_casefrozen——看板/账本用,含进展与重要度。"""
model_config = {"frozen": True}
code: str
title: str
status: 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
links: list[dict[str, Any]] = Field(default_factory=list)
progress: list[dict[str, Any]] = Field(default_factory=list)
class ForeshadowLedgerRepo(Protocol):
"""伏笔账本写侧接口(按 project_id 隔离;写方法只 flush 不 commit"""
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:
"""登记一条伏笔status=OPEN`(project_id, code)` 唯一。"""
...
async def get(self, project_id: uuid.UUID, code: str) -> ForeshadowLedgerView | None:
"""按 code 取单条,无则 None。"""
...
async def list_by_status(
self, project_id: uuid.UUID, status: str | None = None
) -> list[ForeshadowLedgerView]:
"""看板查询按状态过滤None=全部)。供 T3.5 `GET /foreshadow?status=`。"""
...
async def transition(
self, project_id: uuid.UUID, code: str, *, to_status: str
) -> ForeshadowLedgerView:
"""应用状态机校验后写 `status`(非法转移抛 `InvalidTransition`)。"""
...
async def record_progress(
self, project_id: uuid.UUID, code: str, *, entry: dict[str, Any]
) -> ForeshadowLedgerView:
"""append 一条进展到 `progress` JSONBappend-only不覆盖历史"""
...
async def scan_overdue(
self, project_id: uuid.UUID, *, current_chapter: int
) -> list[ForeshadowLedgerView]:
"""到期扫描:命中行置 OVERDUE返回**被改的行**。供 T3.2 验收后扫描。"""
...
def _to_view(row: Foreshadow) -> 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 or []),
progress=list(row.progress or []),
)
class SqlForeshadowLedgerRepo:
"""SQLAlchemy 实现:登记 + 状态机转移 + 进展 append + 到期扫描(只 flush 不 commit"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def _find(self, project_id: uuid.UUID, code: str) -> Foreshadow | None:
return (
await self._s.execute(
select(Foreshadow).where(
Foreshadow.project_id == project_id,
Foreshadow.code == code,
)
)
).scalar_one_or_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:
# 仅 INSERT唯一约束 (project_id, code) 由 DB 强制(不做 ON CONFLICT upsert见模块注释
row = Foreshadow(
project_id=project_id,
code=code,
title=title,
status=ForeshadowStatus.OPEN.value,
planted_at=planted_at,
content=content,
expected_close_from=expected_close_from,
expected_close_to=expected_close_to,
importance=importance,
links=[],
progress=[],
)
self._s.add(row)
await self._s.flush()
await self._s.refresh(row)
return _to_view(row)
async def get(self, project_id: uuid.UUID, code: str) -> ForeshadowLedgerView | None:
row = await self._find(project_id, code)
return _to_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]:
stmt = select(Foreshadow).where(Foreshadow.project_id == project_id)
if status is not None:
stmt = stmt.where(Foreshadow.status == status)
rows = (await self._s.execute(stmt.order_by(Foreshadow.code))).scalars()
return [_to_view(r) for r in rows]
async def transition(
self, project_id: uuid.UUID, code: str, *, to_status: str
) -> ForeshadowLedgerView:
row = await self._find(project_id, code)
if row is None:
raise LookupError(f"foreshadow not found: {code}")
# 状态机校验(非法转移抛 InvalidTransition事务回滚由调用方控制
row.status = apply_transition(
ForeshadowStatus(row.status), ForeshadowStatus(to_status)
).value
await self._s.flush()
await self._s.refresh(row)
return _to_view(row)
async def record_progress(
self, project_id: uuid.UUID, code: str, *, entry: dict[str, Any]
) -> ForeshadowLedgerView:
row = await self._find(project_id, code)
if row is None:
raise LookupError(f"foreshadow not found: {code}")
# 不可变 append建新 list 重赋值,确保 ORM 侦测 JSONB 变更(避免原地 mutate 不脏标记)。
row.progress = [*(row.progress or []), dict(entry)]
await self._s.flush()
await self._s.refresh(row)
return _to_view(row)
async def scan_overdue(
self, project_id: uuid.UUID, *, current_chapter: int
) -> list[ForeshadowLedgerView]:
rows = (
await self._s.execute(select(Foreshadow).where(Foreshadow.project_id == project_id))
).scalars()
changed: list[ForeshadowLedgerView] = []
for row in rows:
new = apply_overdue_scan(
ForeshadowStatus(row.status),
current_chapter=current_chapter,
expected_close_to=row.expected_close_to,
).value
if new != row.status:
row.status = new
changed.append(_to_view(row))
if changed:
await self._s.flush()
return changed

View File

@@ -0,0 +1,105 @@
"""伏笔账本状态机——确定性纯函数ARCH §6.2 / PRODUCT_SPEC §4.2)。
状态机§6.2::
登记 首次照应 完成回收
─────▶ OPEN ──────▶ PARTIAL ─────────▶ CLOSED
│ │
└─────────────┴──▶ OVERDUE (章号 > expected_close_to 且未 CLOSED)
(到期扫描置位; 回收后可转 CLOSED)
- 登记 → OPENDB server_default
- 首次照应 → PARTIAL。
- 完成回收 → CLOSED可从 OPEN/PARTIAL/OVERDUE 一步到达)。
- 到期判定M3-d 纯函数,非 Agent
`current_chapter > expected_close_to AND status≠CLOSED → OVERDUE`。
- **CLOSED 终态**:不被 OVERDUE 覆盖、不可重开(非法转移显式抛 `InvalidTransition`)。
全部为纯函数:不读 DB、不可变、可单测。写库由 `foreshadow_repo` 落地(只 flush
枚举值与 DB `foreshadow.status` 文本严格一致(`StrEnum` → 直接当字符串用)。
"""
from __future__ import annotations
from enum import StrEnum
class ForeshadowStatus(StrEnum):
"""伏笔状态——值与 DB `status` 文本一致。"""
OPEN = "OPEN"
PARTIAL = "PARTIAL"
CLOSED = "CLOSED"
OVERDUE = "OVERDUE"
# 便捷常量(供 repo / 测试 / 调用方按名引用,避免散落字面量)。
OPEN = ForeshadowStatus.OPEN
PARTIAL = ForeshadowStatus.PARTIAL
CLOSED = ForeshadowStatus.CLOSED
OVERDUE = ForeshadowStatus.OVERDUE
class InvalidTransition(ValueError):
"""非法状态转移(如 CLOSED 重开)。"""
# 合法转移图from → 允许到达的状态集合(不含同态幂等,单独放行)。
_ALLOWED: dict[ForeshadowStatus, frozenset[ForeshadowStatus]] = {
OPEN: frozenset({PARTIAL, CLOSED, OVERDUE}),
PARTIAL: frozenset({CLOSED, OVERDUE}),
OVERDUE: frozenset({PARTIAL, CLOSED}), # 逾期后仍可(部分)回收
CLOSED: frozenset(), # 终态:不可再转移
}
def transition(current: ForeshadowStatus, to_status: ForeshadowStatus) -> ForeshadowStatus:
"""应用状态机校验后返回新状态(纯函数)。
- 同态current == to_status幂等放行。
- CLOSED 为终态:任何离开 CLOSED 的转移都非法。
- 其余按 `_ALLOWED` 校验;非法 → `InvalidTransition`。
"""
if current == to_status:
return to_status
if to_status not in _ALLOWED[current]:
raise InvalidTransition(f"非法伏笔转移: {current.value}{to_status.value}")
return to_status
def is_overdue(
*,
current_chapter: int,
expected_close_to: int | None,
status: ForeshadowStatus,
) -> bool:
"""到期判据M3-d§6.2`current_chapter > expected_close_to AND status≠CLOSED`。
- 无 `expected_close_to`(未设回收窗口)→ 永不逾期。
- CLOSED → 永不逾期(已回收)。
- `current_chapter == expected_close_to` → 仍在窗口内,未逾期(严格大于才算)。
"""
if expected_close_to is None:
return False
if status == CLOSED:
return False
return current_chapter > expected_close_to
def apply_overdue_scan(
status: ForeshadowStatus,
*,
current_chapter: int,
expected_close_to: int | None,
) -> ForeshadowStatus:
"""到期扫描应用验收后触发§6.2):命中判据 → 置 `OVERDUE`,否则保持原状态。
幂等:已 OVERDUE 且仍逾期 → 保持 OVERDUECLOSED 永不被改写is_overdue 已排除)。
"""
if is_overdue(
current_chapter=current_chapter,
expected_close_to=expected_close_to,
status=status,
):
return OVERDUE
return status

View File

@@ -0,0 +1,109 @@
"""大纲**写侧** Repository逐章 upsertARCH §5.4 outliner writes=outline / §7.2 POST /outline
读侧(`get`,供 assemble 注入本章 beats已在 `ww_core.memory.sql_repositories.SqlOutlineRepo`
+ `domain.repositories.OutlineRepo`/`OutlineView` 提供C5 稳定,不动)。本模块加**写**能力,
命名加 `Write` 前缀避免与读侧 `OutlineRepo` 同名歧义(同 `DigestAppendRepo`/`ForeshadowLedgerRepo`
区别于读侧的先例,见 memory/decisions
**提交边界**`upsert_chapter` 只 `flush()` 不 `commit()`——提交交端点事务T3.5 端点末尾
一次 `commit()`,与网关 ledger 一并落库;与 M2 验收-side repos 一致,见 memory/gotchas
唯一约束 `(project_id, chapter_no)`:用**显式 read-modify-write**(按 chapter_no 查→有则改、
无则插),不用 PG `ON CONFLICT`(保持与项目其它 upsert 路径一致、可纯 fake 单测,不引 pg 依赖)。
**beats 存储形**DB 列 `outline.beats` 是 JSONB `dict`,而 outliner 产 `list[str]`——
故包裹成 `{"beats": [...]}` 落库,读侧 `OutlineView.beats` 取到同形 dictround-trip 一致)。
`foreshadow_windows` 是 JSONB list直接落 `[w.model_dump() for w in chapter.foreshadow_windows]`。
"""
from __future__ import annotations
import uuid
from typing import Any, Protocol
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import Outline
class OutlineWriteView(BaseModel):
"""单章大纲写入后的只读快照snake_casefrozen"""
model_config = {"frozen": True}
chapter_no: int
volume: int
beats: list[str] = Field(default_factory=list)
foreshadow_windows: list[dict[str, Any]] = Field(default_factory=list)
class OutlineWriteRepo(Protocol):
"""大纲写侧接口(按 project_id 隔离;唯一 `(project_id, chapter_no)`;只 flush 不 commit"""
async def upsert_chapter(
self,
project_id: uuid.UUID,
*,
volume: int,
chapter_no: int,
beats: list[str],
foreshadow_windows: list[dict[str, Any]],
) -> OutlineWriteView:
"""逐章 upsert同 `(project_id, chapter_no)` 覆盖,否则插入。只 flush。"""
...
def _to_view(row: Outline) -> OutlineWriteView:
raw = row.beats or {}
beats = list(raw.get("beats", [])) if isinstance(raw, dict) else []
return OutlineWriteView(
chapter_no=row.chapter_no,
volume=row.volume,
beats=beats,
foreshadow_windows=list(row.foreshadow_windows or []),
)
class SqlOutlineWriteRepo:
"""SQLAlchemy 实现:显式 read-modify-write upsert只 flush 不 commit"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
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 = (
await self._s.execute(
select(Outline).where(
Outline.project_id == project_id,
Outline.chapter_no == chapter_no,
)
)
).scalar_one_or_none()
# JSONB 写入建新对象重赋值(避免原地 mutate 不被 ORM 侦测,见 gotcha
beats_payload = {"beats": list(beats)}
windows_payload = [dict(w) for w in foreshadow_windows]
if row is None:
row = Outline(
project_id=project_id,
volume=volume,
chapter_no=chapter_no,
beats=beats_payload,
foreshadow_windows=windows_payload,
)
self._s.add(row)
else:
row.volume = volume
row.beats = beats_payload
row.foreshadow_windows = windows_payload
await self._s.flush()
await self._s.refresh(row)
return _to_view(row)

View File

@@ -10,9 +10,13 @@ from __future__ import annotations
from .collect import (
CONTINUITY,
FORESHADOW,
PACE,
ReviewRecorder,
collect_reviews,
extract_conflicts,
extract_foreshadow_sug,
extract_pace,
)
from .graph import (
COLLECT_NODE,
@@ -22,6 +26,7 @@ from .graph import (
build_write_graph,
setup_checkpointer,
)
from .outline_node import build_outline_request, run_outline
from .review_node import (
REVIEW_INCOMPLETE,
REVIEW_OK,
@@ -36,6 +41,8 @@ from .sse import (
EVENT_CONFLICT,
EVENT_DONE,
EVENT_ERROR,
EVENT_FORESHADOW,
EVENT_PACE,
EVENT_SECTION,
EVENT_TOKEN,
SECTION_DONE,
@@ -45,8 +52,10 @@ from .sse import (
conflict_event,
done_event,
error_event,
foreshadow_event,
normalize_deltas,
normalize_review,
pace_event,
section_event,
token_event,
)
@@ -59,8 +68,12 @@ __all__ = [
"EVENT_CONFLICT",
"EVENT_DONE",
"EVENT_ERROR",
"EVENT_FORESHADOW",
"EVENT_PACE",
"EVENT_SECTION",
"EVENT_TOKEN",
"FORESHADOW",
"PACE",
"REVIEW_INCOMPLETE",
"REVIEW_OK",
"REVIEW_SPECS",
@@ -74,6 +87,7 @@ __all__ = [
"GatewayStream",
"ReviewRecorder",
"SseEvent",
"build_outline_request",
"build_review_context",
"build_review_graph",
"build_review_request",
@@ -84,10 +98,15 @@ __all__ = [
"done_event",
"error_event",
"extract_conflicts",
"extract_foreshadow_sug",
"extract_pace",
"foreshadow_event",
"make_review_node",
"merge_reviews",
"normalize_deltas",
"normalize_review",
"pace_event",
"run_outline",
"run_review",
"section_event",
"setup_checkpointer",

View File

@@ -1,17 +1,21 @@
"""collect 节点C4 扩 / ARCH §5.2 collect 行)——汇总并行审 → 落 `chapter_reviews` 留痕。
并行审各把 `{spec.name: {status, result}}` 合并进 `state["reviews"]`reducer见 state.py
collect 把它们汇总, `review_repo.record(...)` append 一行 `chapter_reviews`(真相源留痕)
并行审各把 `{spec.name: {status, result}}` 合并进 `state["reviews"]`reducer见 state.py
collect 把它们汇总,**一次** `review_repo.record(...)` append 一行 `chapter_reviews`(真相源留痕)
按 spec.name 把各审结果映射到对应列。
不变量 #3审稿节点只读不写库**唯一写入点是这里经 `review_repo`**(仍是「留痕」非「生效」,
AI 产出真正入库经 T2.4 验收事务的裁决)。
AI 产出真正入库经验收事务的裁决)。
记账边界gotcha「网关 ledger 只 flush」审稿经网关产 usage网关内 `flush` 进同一 session
collect 与 review_repo 同样**只 flush 不 commit**——`commit` 归 HTTP 端点T2.5/验收事务T2.4
与 draft 端点一致(端点在流耗尽后 `await session.commit()`)。本节点绝不 commit。
collect 与 review_repo 同样**只 flush 不 commit**——`commit` 归 HTTP 端点 / 验收事务。
本节点绝不 commit。
M2 只接 continuity从 `reviews["continuity"].result.conflicts` 抽冲突落 `conflicts` 列;
foreshadow/style/pace 列留空M3/M4 接入后填)。设计成按 spec 名取分项,便于扩展。
M3 三审齐 → 列映射(一次 record 落齐本章三审):
- continuity → `conflicts`(冲突清单 list
- foreshadow → `foreshadow_sug`planted/resolved 扁平为建议 list每条带 `kind` 标记);
- pace → `pace`(节奏诊断 dictwater/hook/beat_map
style 第四审留 M4。任一审 `incomplete`网关失败隔离§5.2)→ 该列留空/None不阻塞其余。
"""
from __future__ import annotations
@@ -27,6 +31,12 @@ from .state import ChapterState
log = structlog.get_logger(__name__)
CONTINUITY = "continuity"
FORESHADOW = "foreshadow"
PACE = "pace"
# foreshadow 建议扁平化时的来源标记planted=新埋 / resolved=回收)。
FORESHADOW_KIND_PLANTED = "planted"
FORESHADOW_KIND_RESOLVED = "resolved"
class ReviewRecorder(Protocol):
@@ -49,17 +59,53 @@ class ReviewRecorder(Protocol):
) -> Any: ...
def _ok_result(reviews: dict[str, Any], name: str) -> dict[str, Any] | None:
"""取某审的结果 dict仅当 `ok` 且有结果;否则 Noneincomplete/缺席)。"""
entry = reviews.get(name)
if not entry or entry.get("status") != REVIEW_OK:
return None
result = entry.get("result")
return result if isinstance(result, dict) else None
def extract_conflicts(reviews: dict[str, Any]) -> list[dict[str, Any]]:
"""从 continuity 分项抽冲突清单(纯函数)。
仅当该审 `ok` 且有结果时取其 `conflicts`;未完成/缺席 → 空列表(不臆造)。
"""
entry = reviews.get(CONTINUITY)
if not entry or entry.get("status") != REVIEW_OK:
result = _ok_result(reviews, CONTINUITY)
if result is None:
return []
result = entry.get("result") or {}
conflicts = result.get("conflicts") or []
return [dict(c) for c in conflicts]
return [dict(c) for c in result.get("conflicts") or []]
def extract_foreshadow_sug(reviews: dict[str, Any]) -> list[dict[str, Any]]:
"""从 foreshadow 分项扁平化建议清单(纯函数)。
`ForeshadowReview{planted,resolved}` → 单个 list每条加 `kind` 标记来源
`foreshadow_sug` 列是 JSONB list。未完成/缺席 → 空列表。
"""
result = _ok_result(reviews, FORESHADOW)
if result is None:
return []
out: list[dict[str, Any]] = []
for sug in result.get("planted") or []:
out.append({**dict(sug), "kind": FORESHADOW_KIND_PLANTED})
for sug in result.get("resolved") or []:
out.append({**dict(sug), "kind": FORESHADOW_KIND_RESOLVED})
return out
def extract_pace(reviews: dict[str, Any]) -> dict[str, Any] | None:
"""从 pace 分项取节奏诊断 dict纯函数
`PaceReview{water,hook,beat_map}` 整体入 `pace` 列JSONB dict
未完成/缺席 → None列留空§5.2 不阻塞其余)。
"""
result = _ok_result(reviews, PACE)
if result is None:
return None
return dict(result)
async def collect_reviews(
@@ -67,25 +113,32 @@ async def collect_reviews(
*,
review_repo: ReviewRecorder,
) -> dict[str, Any]:
"""collect 节点:汇总并行审 → 抽冲突 → `review_repo.record` 留痕(只 flush 不 commit
"""collect 节点:汇总并行审 → 各列映射 → 一次 `review_repo.record` 留痕(只 flush 不 commit
直接调用本函数(注入内存 fake repo即可单测无需图运行时。
`chapter_version=None`:留痕行不绑具体 versionT2.4 验收时再绑/裁决R3/R4
`chapter_version=None`:留痕行不绑具体 version验收时再绑/裁决R3/R4
某审 incomplete → 该列空/None不阻塞其余审落库§5.2)。
返回空增量字典——审稿真相已在表state 不作真相源(不变量 #5
"""
reviews = state.get("reviews") or {}
conflicts = extract_conflicts(reviews)
foreshadow_sug = extract_foreshadow_sug(reviews)
pace = extract_pace(reviews)
await review_repo.record(
state["project_id"],
state["chapter_no"],
chapter_version=None,
conflicts=conflicts,
foreshadow_sug=foreshadow_sug,
pace=pace,
)
log.info(
"collect_reviews_recorded",
project_id=str(state["project_id"]),
chapter_no=state["chapter_no"],
conflict_count=len(conflicts),
foreshadow_sug_count=len(foreshadow_sug),
has_pace=pace is not None,
reviews=sorted(reviews.keys()),
)
# 留痕已在表(真相源);不在节点 commit端点/事务层负责,见模块 docstring

View File

@@ -15,7 +15,12 @@ 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 ww_agents import (
AgentSpec,
continuity_spec,
foreshadow_spec,
pace_spec,
)
from .collect import ReviewRecorder, collect_reviews
from .review_node import GatewayRun, run_review
@@ -51,7 +56,8 @@ def build_write_graph(
return builder.compile(checkpointer=checkpointer)
REVIEW_SPECS: tuple[AgentSpec, ...] = (continuity_spec,)
# M3三审齐continuity + foreshadow + pace。文风第四审属 M4/T4.2。
REVIEW_SPECS: tuple[AgentSpec, ...] = (continuity_spec, foreshadow_spec, pace_spec)
def build_review_graph(
@@ -63,9 +69,10 @@ def build_review_graph(
) -> 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 占位)。
M3 默认三审齐(continuity + foreshadow + pace`review_specs` 可扩M4 加 style
第四审),图按这组 spec 循环加并行分支,全部汇入 collect任一审失败不阻塞§5.2——
失败隔离在 `run_review` 内、标 incomplete 占位)。`run_review`/`make_review_node` 对 spec
泛型,三审天然支持(按 `spec.output_schema` 产 parsed无 spec-specific 假设)。
`gateway` / `review_repo` 经闭包绑进节点langgraph 不收 functools.partial见 gotcha
**accept 不在本图**:本任务只交付到 collect审稿留痕落 `chapter_reviews`+ review SSE

View File

@@ -0,0 +1,81 @@
"""大纲节点C6 扩 / ARCH §5.4 outliner 行 / §6.2 伏笔窗口)。
大纲是**独立生成**——不在写章/审稿流水线(不接进 review 图。端点T3.5)直接调
`run_outline` 拿结构化 `OutlineResult`,再持久化到 `outline` 表(含 foreshadow_windows
确定性边界CLAUDE.md「LangGraph」纪律
- 节点逻辑里**无 LLM 非确定性**——不确定性藏在网关后;节点只做
`AgentSpec + context → LlmRequest` 的纯构造 + 转发 `Gateway.run` 的 `parsed`。
- 因此注入 mock 网关(产 `parsed`)即可单测,无需图运行时、无需真 Postgres。
不变量 #2agent 只声明 `tier`,绝不传具体 modelspec.tier 透传)。
不变量 #3节点**只读、不写库**——结构化大纲返回调用方,落库经端点/验收T3.5)。
不变量 #9`spec.system_prompt` 进缓存断点前块cache=True注入材料进 `input`(断点后)。
"""
from __future__ import annotations
import uuid
from typing import Protocol
import structlog
from ww_agents import AgentSpec, OutlineResult
from ww_llm_gateway.types import Block, LlmRequest, LlmResponse, Scope
log = structlog.get_logger(__name__)
class GatewayRun(Protocol):
"""大纲节点对网关的最小依赖——只需 `run`(注入真网关或 mock"""
async def run(self, req: LlmRequest) -> LlmResponse: ...
def build_outline_request(
spec: AgentSpec,
*,
context: str,
user_id: uuid.UUID,
project_id: uuid.UUID,
) -> LlmRequest:
"""据 spec + 注入材料构造大纲请求(纯函数)。
`spec.system_prompt` → `system` 缓存断点前块cache=True不变量 #9
`context`(设定/伏笔/人物/世界观的序列化文本)→ `input`(断点后)。
排大纲用 `run()` 非 `stream()`(要结构化产物,非流式正文)。
"""
return LlmRequest(
tier=spec.tier, # 不变量 #2只传档位不传 model
system=[Block(text=spec.system_prompt, cache=True)],
input=context,
output_schema=spec.output_schema,
scope=Scope(user_id=user_id, project_id=project_id),
)
async def run_outline(
spec: AgentSpec,
*,
context: str,
gateway: GatewayRun,
user_id: uuid.UUID,
project_id: uuid.UUID,
) -> OutlineResult:
"""跑大纲生成:构请求 → `gateway.run` → 返回结构化 `OutlineResult`。
裸函数(显式 gateway 关键字),注入 mock 网关即可单测,无需图运行时。
大纲是独立生成(非并行审):网关失败直接上抛(端点/T3.5 处理),不静默吞。
**只产结构化大纲、不写库**(持久化属 T3.5,不变量 #3
"""
req = build_outline_request(spec, context=context, user_id=user_id, project_id=project_id)
resp = await gateway.run(req)
parsed = resp.parsed
if not isinstance(parsed, OutlineResult):
# 带 output_schema 时 parsed 必非 NoneC1违约则明确报错而非返回空大纲。
log.error(
"outline_node_missing_parsed",
project_id=str(project_id),
parsed_type=type(parsed).__name__,
)
raise ValueError("gateway returned no parsed OutlineResult for outline request")
return parsed

View File

@@ -18,6 +18,7 @@ from pydantic import BaseModel
from ww_llm_gateway.types import Delta
from ww_shared import AppError, ErrorCode
from .collect import CONTINUITY, FORESHADOW, PACE
from .review_node import REVIEW_OK
log = structlog.get_logger(__name__)
@@ -25,7 +26,9 @@ log = structlog.get_logger(__name__)
# §7.3 事件名(全集)。完整清单以 ARCHITECTURE §7.3 为准。
EVENT_TOKEN = "token" # 正文增量(写章 draft
EVENT_SECTION = "section" # 四审分项开始/完成(审稿 review
EVENT_CONFLICT = "conflict" # 冲突命中(审稿 review
EVENT_CONFLICT = "conflict" # continuity 冲突命中(审稿 review
EVENT_FORESHADOW = "foreshadow" # foreshadow 建议命中(新埋/回收,审稿 review
EVENT_PACE = "pace" # pace 节奏诊断(注水/钩子/节拍图,审稿 review
EVENT_DONE = "done"
EVENT_ERROR = "error"
@@ -72,6 +75,35 @@ def conflict_event(
)
def foreshadow_event(
*,
kind: str,
code: str | None,
title: str,
where: str | None,
note: str | None,
) -> SseEvent:
"""伏笔建议事件(审稿 review——前端伏笔看板联动。
`kind` ∈ planted新埋/ resolved回收作者验收裁决后才登记/改状态(只读建议)。
"""
return SseEvent(
event=EVENT_FORESHADOW,
data={"kind": kind, "code": code, "title": title, "where": where, "note": note},
)
def pace_event(*, water: list[dict[str, object]], hook: bool, beat_map: list[int]) -> SseEvent:
"""节奏诊断事件(审稿 review——前端节奏报告/节拍图 ▁▃▅ 可视化。
一次审产一条:注水段清单 + 章末钩子有无 + 逐段节拍强度序列。
"""
return SseEvent(
event=EVENT_PACE,
data={"water": water, "hook": hook, "beat_map": beat_map},
)
def error_event(*, code: str, message: str, request_id: str | None = None) -> SseEvent:
"""错误事件,形对齐错误信封 §7.1`code`/`message`),附 `request_id` 便于贯通排查。"""
return SseEvent(
@@ -122,40 +154,79 @@ async def normalize_deltas(
yield done_event(length=total)
def _section_result_events(name: str, result: dict[str, Any]) -> list[SseEvent]:
"""把某审 `result` dict 按审种 surface 为结构化事件(纯函数,便于单测)。
畸形 result非 dict 等)由调用方 try/except 兜成 `error` 事件;这里只按已知形读。
"""
events: list[SseEvent] = []
if name == CONTINUITY:
for conflict in result.get("conflicts") or []:
events.append(
conflict_event(
type=conflict.get("type", ""),
where=conflict.get("where", ""),
refs=list(conflict.get("refs") or []),
suggestion=conflict.get("suggestion", ""),
)
)
elif name == FORESHADOW:
for kind in ("planted", "resolved"):
for sug in result.get(kind) or []:
events.append(
foreshadow_event(
kind=kind,
code=sug.get("code"),
title=sug.get("title", ""),
where=sug.get("where"),
note=sug.get("note"),
)
)
elif name == PACE:
events.append(
pace_event(
water=[dict(w) for w in result.get("water") or []],
hook=bool(result.get("hook", False)),
beat_map=[int(b) for b in result.get("beat_map") or []],
)
)
return events
async def normalize_review(
reviews: Mapping[str, Any],
*,
request_id: str | None = None,
) -> AsyncIterator[SseEvent]:
"""把审稿子图的结构化产出(`state["reviews"]`)归一为 SSE 事件序列。
"""把审稿子图的结构化产出(`state["reviews"]`)归一为 SSE 事件序列M3 三审齐)
**T2.5** 的 `POST .../review` 端点消费:跑 `build_review_graph` 后拿 `final["reviews"]`
(形 `{name: {status, result}}`),本函数 `section`每审 done/incomplete+ `conflict`
(命中冲突,形对齐 C6 `Conflict`+ `done`。HTTP event-stream 编码归 T2.5,不在此处。
供 `POST .../review` 端点消费:跑 `build_review_graph` 后拿 `final["reviews"]`
(形 `{name: {status, result}}`),本函数每审先发 `section`done/incomplete,再按审种
surface 结构化结果:
- continuity → 每冲突一条 `conflict`(形对齐 C6 `Conflict`
- foreshadow → 每条建议一条 `foreshadow`planted/resolved前端看板联动
- pace → 一条 `pace`(注水/钩子/节拍图,前端节奏报告/▁▃▅)。
末尾 `done{length=审项数}`。HTTP event-stream 编码归端点,不在此处。
错误处理纪律(同 `normalize_deltas`):任何意外 → 一条 `error` 事件后正常收尾,不上抛。
某审 `incomplete`网关失败被隔离§5.2)→ 发 `section{status:incomplete}`,不发其冲突
`reviews` 键按字典序遍历 → 确定性事件顺序(便于单测/前端稳定渲染)。
某审 `incomplete`网关失败被隔离§5.2)→ 发 `section{status:incomplete}`,不发其结果
`reviews` 键按字典序遍历 → 确定性事件顺序(便于单测/前端稳定渲染)。向后兼容:
既有 `section`/`conflict` 形不变,仅新增 `foreshadow`/`pace`。
"""
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:
if status != REVIEW_OK:
yield section_event(name=name, status=SECTION_INCOMPLETE)
section_count += 1
continue
yield section_event(name=name, status=SECTION_DONE)
section_count += 1
result = entry.get("result") or {}
for event in _section_result_events(name, result):
yield event
except Exception as exc: # noqa: BLE001 — 边界兜底:任何意外都归一为 error 事件,不泄异常
log.error("sse_review_unexpected_error", error=str(exc), request_id=request_id)
yield error_event(