Files
writer-work-flow/apps/api/ww_api/services/accept_service.py
Yaojia Wang 5fb7bfb1de 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
2026-06-18 14:21:17 +02:00

139 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""验收事务 + 冲突 gateT2.4ARCH §5.5 四步,不变量 #3/#4/#5
把作者裁决后的变更**单原子事务**落库R3/R4晋升终稿 → 终稿提炼 digest →
裁决留痕 →(占位)人物/伏笔状态。digest 提炼在**事务外**先做R2
冲突 gateR5事务前拦截核对最近一次审稿留痕的**每个冲突**在裁决清单里都有
采纳/忽略/手改之一;缺判 → `AppError(CONFLICT_UNRESOLVED)`,不写库。
accept 是**确定性事务代码**,不跑 graph 正文/审稿节点;真相从领域表(`chapters` /
`chapter_reviews`重读R3不变量 #5。提交边界全部只 flush → 末尾一次
`await session.commit()`;任一步失败 → 整体回滚。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
import structlog
from ww_core.domain.chapter_repo import ChapterRepo
from ww_core.domain.digest_repo import DigestAppendRepo
from ww_core.domain.review_repo import ReviewRepo, ReviewView
from ww_shared import AppError, ErrorCode
from ww_api.schemas.projects import ConflictDecision
log = structlog.get_logger(__name__)
@dataclass(frozen=True)
class AcceptOutcome:
"""验收事务结果(供端点组「本次将更新」清单)。"""
accepted_version: int
digest_added: bool
decisions_recorded: int
review_id: uuid.UUID | None
def assert_conflicts_resolved(
latest_review: ReviewView | None,
decisions: list[ConflictDecision],
) -> None:
"""冲突 gateR5最近审稿的每个冲突都须有裁决否则 `CONFLICT_UNRESOLVED`。
判据:以最近审稿留痕的 `conflicts` 列表下标为冲突身份;裁决清单里出现的
`conflict_index` 集合必须**覆盖** `range(len(conflicts))`。缺判 → 拦截(不写库)。
无审稿留痕或零冲突 → 直接通过(无需裁决)。纯函数:只判定、不副作用。
"""
if latest_review is None:
return
conflict_count = len(latest_review.conflicts)
if conflict_count == 0:
return
decided = {d.conflict_index for d in decisions}
missing = [i for i in range(conflict_count) if i not in decided]
if missing:
raise AppError(
ErrorCode.CONFLICT_UNRESOLVED,
"存在未裁决的冲突,无法验收:请对每个冲突选择 采纳/忽略/手改",
{"missing_conflict_indices": missing, "conflict_count": conflict_count},
)
def _serialize_decisions(
latest_review: ReviewView | None,
decisions: list[ConflictDecision],
) -> dict[str, object]:
"""把裁决清单序列化为可落 `chapter_reviews.decisions` 的 JSON 形(确定性)。"""
return {
"items": [
{
"conflict_index": d.conflict_index,
"verdict": d.verdict,
"note": d.note,
}
for d in sorted(decisions, key=lambda x: x.conflict_index)
],
"conflict_count": len(latest_review.conflicts) if latest_review else 0,
}
async def run_accept_transaction(
*,
session: object,
chapter_repo: ChapterRepo,
digest_repo: DigestAppendRepo,
review_repo: ReviewRepo,
project_id: uuid.UUID,
chapter_no: int,
final_text: str,
digest_facts: dict[str, object],
latest_review: ReviewView | None,
decisions: list[ConflictDecision],
) -> AcceptOutcome:
"""单原子事务落库R3/R4§5.5 步骤 14末尾一次 commit。
`digest_facts` 已在事务外提炼好R2。各 repo 写方法只 flush本函数统一在末尾
`await session.commit()`,任一步抛错由调用方/上下文回滚(不显式半提交)。
`session` 类型用 object 以免绑定 SQLAlchemy测试注入 fake session 亦可)。
"""
# 步骤 1终稿晋升 accepted 新 versionmax+1草稿行保留R4
chapter = await chapter_repo.promote_to_accepted(project_id, chapter_no, content=final_text)
# 步骤 2终稿 digest 追加append-only不变量 #4
await digest_repo.append(project_id, chapter_no, facts=digest_facts)
# 步骤 4裁决留痕写到最近一次审稿行无审稿行则跳过
review_id: uuid.UUID | None = None
if latest_review is not None:
updated = await review_repo.set_decisions(
latest_review.id,
decisions=_serialize_decisions(latest_review, decisions),
)
review_id = updated.id
# 步骤 3伏笔到期扫描——验收提交**后**经端点登记的 BackgroundTask 跑M3-b/d
# `services/foreshadow_scan.run_overdue_scan`current_ch > expected_close_to 且未 CLOSED
# → 置 OVERDUE确定性纯函数非 AI不变量 #3。放在事务外/提交后是因为它是验收**结果**
# 的副作用、且自建独立 session请求 session 此时已关闭)。
# 人物 latest_state 更新仍留后续M4+):本事务只落晋升 + digest + 裁决留痕。
await session.commit() # type: ignore[attr-defined] # AsyncSession.commit()fake 同形)
log.info(
"chapter_accepted",
project_id=str(project_id),
chapter_no=chapter_no,
accepted_version=chapter.version,
decisions_recorded=len(decisions),
review_id=str(review_id) if review_id else None,
)
return AcceptOutcome(
accepted_version=chapter.version,
digest_added=True,
decisions_recorded=len(decisions),
review_id=review_id,
)