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

@@ -8,10 +8,19 @@ from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
from pydantic import BaseModel
from ww_core.domain import ForeshadowLedgerView, OutlineWriteView
from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView
from ww_core.domain.foreshadow_state import (
ForeshadowStatus,
apply_overdue_scan,
)
from ww_core.domain.foreshadow_state import (
transition as apply_transition,
)
from ww_core.domain.project_repo import ProjectCreate, ProjectView
from ww_core.domain.repositories import DigestView
from ww_core.domain.review_repo import ReviewView
@@ -172,14 +181,156 @@ class FakeDigestAppendRepo:
class FakeSession:
"""最小 fake记录 commit 次数(验收事务边界断言)。"""
"""最小 fake记录 commit/rollback 次数(验收事务 + 端点提交边界断言)。"""
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
class FakeForeshadowRepo:
"""实现 `ForeshadowLedgerRepo` Protocol内存状态机/进展/扫描复用纯函数)。
`raise_integrity_on` 模拟重复 code 的 DB 唯一约束冲突register 抛 IntegrityError
"""
def __init__(self, *, raise_integrity_on: set[str] | None = None) -> None:
self.rows: dict[tuple[uuid.UUID, str], ForeshadowLedgerView] = {}
self._raise_integrity_on = raise_integrity_on or set()
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 code in self._raise_integrity_on or (project_id, code) in self.rows:
from sqlalchemy.exc import IntegrityError
raise IntegrityError("duplicate code", None, Exception("unique"))
view = ForeshadowLedgerView(
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,
)
self.rows[(project_id, code)] = view
return view
async def get(self, project_id: uuid.UUID, code: str) -> ForeshadowLedgerView | None:
return self.rows.get((project_id, code))
async def list_by_status(
self, project_id: uuid.UUID, status: str | None = None
) -> list[ForeshadowLedgerView]:
rows = [v for (p, _), v in self.rows.items() if p == project_id]
if status is not None:
rows = [v for v in rows if v.status == status]
return sorted(rows, key=lambda v: v.code)
async def transition(
self, project_id: uuid.UUID, code: str, *, to_status: str
) -> ForeshadowLedgerView:
row = self.rows.get((project_id, code))
if row is None:
raise LookupError(f"foreshadow not found: {code}")
new_status = apply_transition(
ForeshadowStatus(row.status), ForeshadowStatus(to_status)
).value
updated = row.model_copy(update={"status": new_status})
self.rows[(project_id, code)] = updated
return updated
async def record_progress(
self, project_id: uuid.UUID, code: str, *, entry: dict[str, Any]
) -> ForeshadowLedgerView:
row = self.rows.get((project_id, code))
if row is None:
raise LookupError(f"foreshadow not found: {code}")
updated = row.model_copy(update={"progress": [*row.progress, dict(entry)]})
self.rows[(project_id, code)] = updated
return updated
async def scan_overdue(
self, project_id: uuid.UUID, *, current_chapter: int
) -> list[ForeshadowLedgerView]:
changed: list[ForeshadowLedgerView] = []
for key, row in list(self.rows.items()):
new_status = apply_overdue_scan(
ForeshadowStatus(row.status),
current_chapter=current_chapter,
expected_close_to=row.expected_close_to,
).value
if new_status != row.status:
updated = row.model_copy(update={"status": new_status})
self.rows[key] = updated
changed.append(updated)
return changed
class FakeOutlineWriteRepo:
"""实现 `OutlineWriteRepo` Protocol内存幂等覆盖同 (project_id, chapter_no))。"""
def __init__(self) -> None:
# (project_id, chapter_no) -> (volume, beats, foreshadow_windows)
self.rows: dict[tuple[uuid.UUID, int], tuple[int, list[str], list[dict[str, Any]]]] = {}
self.upsert_calls = 0
async def upsert_chapter(
self,
project_id: uuid.UUID,
*,
volume: int,
chapter_no: int,
beats: list[str],
foreshadow_windows: list[dict[str, Any]],
) -> OutlineWriteView:
self.upsert_calls += 1
windows = [dict(w) for w in foreshadow_windows]
self.rows[(project_id, chapter_no)] = (volume, list(beats), windows)
return OutlineWriteView(
chapter_no=chapter_no,
volume=volume,
beats=list(beats),
foreshadow_windows=windows,
)
class FakeSessionFactory:
"""到期扫描的独立 session 工厂替身:`()` → async-CM 产 `FakeSession`。
记录开了几次 session + 各次 commit供「扫描自建 session 并提交」断言。
"""
def __init__(self) -> None:
self.sessions: list[FakeSession] = []
def __call__(self) -> Any:
session = FakeSession()
self.sessions.append(session)
@asynccontextmanager
async def _cm() -> AsyncIterator[FakeSession]:
yield session
return _cm()
class FakeReviewGateway:
"""实现审稿/digest 节点最小依赖(`run`):吐固定结构化 `parsed`,绝不联网。"""

View File

@@ -0,0 +1,203 @@
"""T3.2 伏笔登记/状态变更端点 + 验收后到期扫描(内存替身,无 DB/无网络)。
覆盖:
- 登记 201 + 重复 code → 422 VALIDATION 信封;
- 状态机转移 200 / 非法转移 → 422 VALIDATION / 不存在 → 404
- 进展 append空更新 → 422
- 到期扫描纯函数 `run_overdue_scan` 直接 await埋 expected_close_to < 验收章号的伏笔
→ 置 OVERDUE、自建 session 提交(**绝不真起后台线程**)。
"""
from __future__ import annotations
import uuid
import httpx
import pytest
from fakes_projects import (
FakeForeshadowRepo,
FakeSession,
FakeSessionFactory,
)
from ww_api.services.foreshadow_scan import run_overdue_scan
from ww_core.domain.foreshadow_state import ForeshadowStatus
from ww_shared import ErrorCode
def _make_client(
*, repo: FakeForeshadowRepo | None = None, session: FakeSession | None = None
) -> tuple[httpx.AsyncClient, FakeForeshadowRepo, FakeSession]:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import get_foreshadow_repo
from ww_db import get_session
repo = repo or FakeForeshadowRepo()
session = session or FakeSession()
app = create_app()
app.dependency_overrides[get_foreshadow_repo] = lambda: repo
app.dependency_overrides[get_session] = lambda: session
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, repo, session
# ---- 登记 ----
@pytest.mark.asyncio
async def test_register_foreshadow_returns_201_open() -> None:
client, repo, session = _make_client()
pid = uuid.uuid4()
async with client:
resp = await client.post(
f"/projects/{pid}/foreshadow",
json={"code": "F1", "title": "神秘信物", "planted_at": 3, "expected_close_to": 10},
)
assert resp.status_code == 201
body = resp.json()
assert body["code"] == "F1"
assert body["status"] == ForeshadowStatus.OPEN.value
assert body["expected_close_to"] == 10
assert session.commits == 1
assert (pid, "F1") in repo.rows
@pytest.mark.asyncio
async def test_register_duplicate_code_maps_to_422_validation() -> None:
repo = FakeForeshadowRepo(raise_integrity_on={"F1"})
client, _, session = _make_client(repo=repo)
pid = uuid.uuid4()
async with client:
resp = await client.post(
f"/projects/{pid}/foreshadow", json={"code": "F1", "title": "重复"}
)
assert resp.status_code == 422
err = resp.json()["error"]
assert err["code"] == ErrorCode.VALIDATION
assert err["details"]["reason"] == "duplicate"
# 冲突后回滚、不提交。
assert session.commits == 0
assert session.rollbacks == 1
# ---- 状态变更 ----
@pytest.mark.asyncio
async def test_transition_open_to_partial_200() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="t")
client, _, session = _make_client(repo=repo)
async with client:
resp = await client.patch(f"/projects/{pid}/foreshadow/F1", json={"to_status": "PARTIAL"})
assert resp.status_code == 200
assert resp.json()["status"] == "PARTIAL"
assert session.commits == 1
@pytest.mark.asyncio
async def test_invalid_transition_maps_to_422_validation() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="t")
await repo.transition(pid, "F1", to_status="CLOSED") # 先到终态
client, _, session = _make_client(repo=repo)
async with client:
resp = await client.patch(
f"/projects/{pid}/foreshadow/F1",
json={"to_status": "PARTIAL"}, # CLOSED→PARTIAL 非法
)
assert resp.status_code == 422
err = resp.json()["error"]
assert err["code"] == ErrorCode.VALIDATION
assert err["details"]["reason"] == "invalid_transition"
assert session.rollbacks == 1
@pytest.mark.asyncio
async def test_transition_unknown_code_404() -> None:
client, _, _ = _make_client()
pid = uuid.uuid4()
async with client:
resp = await client.patch(f"/projects/{pid}/foreshadow/NOPE", json={"to_status": "PARTIAL"})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_record_progress_appends() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="t")
client, _, session = _make_client(repo=repo)
async with client:
resp = await client.patch(
f"/projects/{pid}/foreshadow/F1",
json={"progress_entry": {"chapter_no": 5, "note": "首次照应"}},
)
assert resp.status_code == 200
assert resp.json()["progress"][0]["note"] == "首次照应"
assert session.commits == 1
@pytest.mark.asyncio
async def test_empty_update_maps_to_422() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="t")
client, _, _ = _make_client(repo=repo)
async with client:
resp = await client.patch(f"/projects/{pid}/foreshadow/F1", json={})
assert resp.status_code == 422
assert resp.json()["error"]["details"]["reason"] == "empty_update"
# ---- 验收后到期扫描(纯函数直接 await不起后台线程----
@pytest.mark.asyncio
async def test_run_overdue_scan_marks_overdue_and_commits() -> None:
# Arrange一条 expected_close_to=5 的伏笔,验收章号 7 > 5 → 应置 OVERDUE。
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="逾期", expected_close_to=5)
await repo.register(pid, code="F2", title="窗口内", expected_close_to=9)
factory = FakeSessionFactory()
# Act经 repo_factory 缝注入 fake 账本 repo不联网/不真起后台线程)。
count = await run_overdue_scan(
factory,
project_id=pid,
chapter_no=7,
request_id="req-1",
repo_factory=lambda _session: repo,
)
# Assert
assert count == 1
assert repo.rows[(pid, "F1")].status == ForeshadowStatus.OVERDUE.value
assert repo.rows[(pid, "F2")].status == ForeshadowStatus.OPEN.value
# 自建 session 且有变更 → 提交一次。
assert len(factory.sessions) == 1
assert factory.sessions[0].commits == 1
@pytest.mark.asyncio
async def test_run_overdue_scan_no_change_skips_commit() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="窗口内", expected_close_to=9)
factory = FakeSessionFactory()
count = await run_overdue_scan(
factory, project_id=pid, chapter_no=3, repo_factory=lambda _session: repo
)
assert count == 0
# 空扫描不提交。
assert factory.sessions[0].commits == 0

View File

@@ -0,0 +1,216 @@
"""T3.5 端点测试:大纲生成 + 持久化 + 伏笔看板(内存替身,无 DB/无网络)。
覆盖:
- POST /outlinemock 网关产 `OutlineResult` → 逐章 upsert含 foreshadow_windows→ 端点 commit
- 幂等:同 (project_id, chapter_no) 再生成覆盖;
- 项目不存在 → 404无凭据 → LLM_UNAVAILABLE503
- GET /foreshadow?status=:按状态过滤;非法 status → 422 VALIDATION。
"""
from __future__ import annotations
import os
import uuid
import httpx
import pytest
from fakes_projects import (
FakeForeshadowRepo,
FakeOutlineWriteRepo,
FakeProjectRepo,
FakeReviewGateway,
FakeSession,
)
from test_projects import _empty_memory_repos
from ww_agents import ForeshadowWindow, OutlineChapter, OutlineResult
from ww_core.domain.project_repo import ProjectCreate
from ww_shared import AppError, ErrorCode
def _outline_result() -> OutlineResult:
return OutlineResult(
chapters=[
OutlineChapter(
no=1,
beats=["开篇引入主角", "埋下信物伏笔"],
foreshadow_windows=[
ForeshadowWindow(code="F1", plant_chapter=1, expected_close_to=10)
],
),
OutlineChapter(no=2, beats=["冲突升级"], foreshadow_windows=[]),
]
)
def _make_client(
*,
project_repo: FakeProjectRepo | None = None,
foreshadow_repo: FakeForeshadowRepo | None = None,
outline_repo: FakeOutlineWriteRepo | None = None,
gateway: FakeReviewGateway | None = None,
session: FakeSession | None = None,
) -> tuple[httpx.AsyncClient, FakeOutlineWriteRepo, FakeSession]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_foreshadow_repo,
get_memory_repos,
get_outline_gateway,
get_outline_write_repo,
get_project_repo,
)
from ww_db import get_session
project_repo = project_repo or FakeProjectRepo()
foreshadow_repo = foreshadow_repo or FakeForeshadowRepo()
outline_repo = outline_repo or FakeOutlineWriteRepo()
gateway = gateway or FakeReviewGateway(parsed=_outline_result())
session = session or FakeSession()
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_foreshadow_repo] = lambda: foreshadow_repo
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
app.dependency_overrides[get_outline_write_repo] = lambda: outline_repo
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_outline_gateway] = lambda: gateway
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, outline_repo, session
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
view = await repo.create(uuid.UUID(int=1), ProjectCreate(title="测试作品", genre="玄幻"))
return uuid.UUID(str(view.id))
# ---- 大纲生成 ----
@pytest.mark.asyncio
async def test_generate_outline_upserts_chapters_and_commits() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
client, outline_repo, session = _make_client(project_repo=project_repo)
async with client:
resp = await client.post(f"/projects/{pid}/outline", json={"volume": 1})
assert resp.status_code == 200
body = resp.json()
assert [c["no"] for c in body["chapters"]] == [1, 2]
ch1 = body["chapters"][0]
assert ch1["beats"] == ["开篇引入主角", "埋下信物伏笔"]
assert ch1["foreshadow_windows"][0]["code"] == "F1"
assert ch1["foreshadow_windows"][0]["expected_close_to"] == 10
# 逐章 upsert2 章)+ 端点末尾一次 commit。
assert outline_repo.upsert_calls == 2
assert (pid, 1) in outline_repo.rows
assert outline_repo.rows[(pid, 1)][1] == ["开篇引入主角", "埋下信物伏笔"]
assert session.commits == 1
@pytest.mark.asyncio
async def test_generate_outline_is_idempotent_overwrites_same_chapter() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
outline_repo = FakeOutlineWriteRepo()
client, _, _ = _make_client(project_repo=project_repo, outline_repo=outline_repo)
async with client:
await client.post(f"/projects/{pid}/outline", json={"volume": 1})
await client.post(f"/projects/{pid}/outline", json={"volume": 1})
# 同 (project_id, chapter_no) 覆盖,不堆叠 → 仍只 2 行。
assert len(outline_repo.rows) == 2
@pytest.mark.asyncio
async def test_generate_outline_unknown_project_404() -> None:
client, _, _ = _make_client()
async with client:
resp = await client.post(f"/projects/{uuid.uuid4()}/outline", json={})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_generate_outline_without_credentials_maps_to_llm_unavailable() -> None:
# 无凭据时 `build_gateway_for_tier` 抛 LLM_UNAVAILABLE503在网关 dep 解析阶段发生。
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
session = FakeSession()
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_foreshadow_repo,
get_memory_repos,
get_outline_gateway,
get_outline_write_repo,
get_project_repo,
)
from ww_db import get_session
async def _no_creds() -> object:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = create_app()
app.dependency_overrides[get_project_repo] = lambda: project_repo
app.dependency_overrides[get_foreshadow_repo] = lambda: FakeForeshadowRepo()
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
app.dependency_overrides[get_outline_write_repo] = lambda: FakeOutlineWriteRepo()
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_outline_gateway] = _no_creds
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(f"/projects/{pid}/outline", json={})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
# 网关阶段失败 → 未生成、未提交。
assert session.commits == 0
@pytest.mark.asyncio
async def test_generate_outline_upper_error_propagates() -> None:
project_repo = FakeProjectRepo()
pid = await _seed_project(project_repo)
gateway = FakeReviewGateway(error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
client, _, session = _make_client(project_repo=project_repo, gateway=gateway)
async with client:
resp = await client.post(f"/projects/{pid}/outline", json={})
assert resp.status_code == 503
assert session.commits == 0
# ---- 伏笔看板 ----
@pytest.mark.asyncio
async def test_foreshadow_board_filters_by_status() -> None:
repo = FakeForeshadowRepo()
pid = uuid.uuid4()
await repo.register(pid, code="F1", title="开放伏笔")
await repo.register(pid, code="F2", title="部分回收")
await repo.transition(pid, "F2", to_status="PARTIAL")
from ww_api.main import create_app
from ww_api.services.project_deps import get_foreshadow_repo
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
app = create_app()
app.dependency_overrides[get_foreshadow_repo] = lambda: repo
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
all_resp = await client.get(f"/projects/{pid}/foreshadow")
open_resp = await client.get(f"/projects/{pid}/foreshadow", params={"status": "OPEN"})
bad_resp = await client.get(f"/projects/{pid}/foreshadow", params={"status": "NOPE"})
assert {f["code"] for f in all_resp.json()["foreshadow"]} == {"F1", "F2"}
open_codes = [f["code"] for f in open_resp.json()["foreshadow"]]
assert open_codes == ["F1"]
assert bad_resp.status_code == 422
assert bad_resp.json()["error"]["code"] == ErrorCode.VALIDATION
assert bad_resp.json()["error"]["details"]["reason"] == "invalid_status"

View File

@@ -0,0 +1,63 @@
"""T3.5 大纲注入上下文组装单测确定性纯函数ARCH §5.4)。
断言:四源(设定/伏笔/人物/世界观)都进文本、排序确定(同输入同输出)、空源不炸。
"""
from __future__ import annotations
from ww_api.services.outline_context import build_outline_context
from ww_core.domain import ForeshadowLedgerView
from ww_core.domain.project_repo import ProjectView
from ww_core.domain.repositories import CharacterView, WorldEntityView
def _project() -> ProjectView:
import uuid
return ProjectView(
id=uuid.UUID(int=1),
title="逆天剑帝",
genre="玄幻",
logline="少年逆袭",
selling_points=["爽点密集", "升级流"],
)
def test_build_context_includes_all_four_sources() -> None:
text = build_outline_context(
project=_project(),
foreshadow=[
ForeshadowLedgerView(
code="F1", title="神秘信物", status="OPEN", planted_at=3, expected_close_to=10
)
],
characters=[CharacterView(name="主角", role="protagonist", motive="复仇")],
world_entities=[WorldEntityView(type="location", name="剑冢")],
)
assert "逆天剑帝" in text
assert "F1" in text and "神秘信物" in text
assert "回收窗口 ?-10" in text
assert "主角" in text
assert "剑冢" in text
def test_build_context_is_deterministic_and_sorted() -> None:
project = _project()
fs = [
ForeshadowLedgerView(code="F2", title="b", status="OPEN"),
ForeshadowLedgerView(code="F1", title="a", status="OPEN"),
]
a = build_outline_context(project=project, foreshadow=fs, characters=[], world_entities=[])
b = build_outline_context(
project=project, foreshadow=list(reversed(fs)), characters=[], world_entities=[]
)
assert a == b
# F1 排在 F2 前(按 code 升序)。
assert a.index("[F1]") < a.index("[F2]")
def test_build_context_handles_empty_sources() -> None:
text = build_outline_context(
project=_project(), foreshadow=[], characters=[], world_entities=[]
)
assert "(无)" in text

View File

@@ -17,6 +17,7 @@ from fakes_projects import (
FakeReviewGateway,
FakeReviewRepo,
FakeSession,
FakeSessionFactory,
)
from test_projects import _empty_memory_repos
from ww_agents import ContinuityReview
@@ -50,6 +51,7 @@ def _make_client(
get_memory_repos,
get_review_gateway,
get_review_repo,
get_session_factory,
)
from ww_db import get_session
@@ -68,6 +70,7 @@ def _make_client(
app.dependency_overrides[get_review_gateway] = lambda: review_gateway
app.dependency_overrides[get_digest_gateway] = lambda: digest_gateway
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory()
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, chapter_repo, review_repo, digest_repo, session

View File

@@ -12,7 +12,14 @@ from ww_shared import AppError, ErrorBody, ErrorEnvelope
from ww_api.logging_config import configure_logging, get_logger
from ww_api.middleware import request_id_middleware
from ww_api.routers import health, jobs, projects, settings_providers
from ww_api.routers import (
foreshadow,
health,
jobs,
outline,
projects,
settings_providers,
)
from ww_api.services.project_deps import seed_stub_user
configure_logging()
@@ -48,6 +55,8 @@ def create_app() -> FastAPI:
app.include_router(health.router)
app.include_router(jobs.router)
app.include_router(projects.router)
app.include_router(foreshadow.router)
app.include_router(outline.router)
app.include_router(settings_providers.router)
return app

View File

@@ -0,0 +1,159 @@
"""伏笔登记 / 状态变更端点C3 扩 / ARCH §6.2, §7.2;不变量 #3
- POST /projects/:id/foreshadow 作者显式登记一条伏笔status=OPEN
- PATCH /projects/:id/foreshadow/:code 状态机转移 和/或 追加进展。
登记/改状态是**作者显式动作**(不变量 #3伏笔入库不经 AI 静默写库;验收后到期扫描
是确定性纯函数置位,见 `services/foreshadow_scan.py`)。
提交边界:`ForeshadowLedgerRepo` 写方法只 `flush()`,端点写后 `await session.commit()`。
**T3.5 续接点**:本 router 是 foreshadow 端点的归属处。T3.5 的看板查询
`GET /projects/:id/foreshadow?status=` 直接加到本 router调 `repo.list_by_status`
大纲 `POST /projects/:id/outline` 与伏笔无关,建议落独立 `routers/outline.py`
(或 projects router不混进本文件。
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain import ForeshadowLedgerRepo, ForeshadowLedgerView
from ww_core.domain.foreshadow_state import ForeshadowStatus, InvalidTransition
from ww_db import get_session
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.foreshadow import (
ForeshadowBoardResponse,
ForeshadowRegisterRequest,
ForeshadowTransitionRequest,
ForeshadowView,
)
from ww_api.services.project_deps import get_foreshadow_repo
log = get_logger("ww.api.foreshadow")
router = APIRouter(prefix="/projects", tags=["foreshadow"])
ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
def _to_view(v: ForeshadowLedgerView) -> ForeshadowView:
# ForeshadowLedgerView 与 ForeshadowView 字段同名逐字段映射snake_case 契约)。
return ForeshadowView.model_validate(v, from_attributes=True)
@router.post("/{project_id}/foreshadow", status_code=201)
async def register_foreshadow(
project_id: uuid.UUID,
body: ForeshadowRegisterRequest,
request: Request,
repo: ForeshadowRepoDep,
session: SessionDep,
) -> ForeshadowView:
"""登记一条伏笔status=OPEN。重复 `code` → DB 唯一约束冲突 → VALIDATION 信封。"""
request_id = getattr(request.state, "request_id", None)
try:
view = await repo.register(
project_id,
code=body.code,
title=body.title,
planted_at=body.planted_at,
content=body.content,
expected_close_from=body.expected_close_from,
expected_close_to=body.expected_close_to,
importance=body.importance,
)
await session.commit()
except IntegrityError as exc:
# `(project_id, code)` 唯一约束冲突register 仅 INSERT、不 upsert见 foreshadow_repo
await session.rollback()
raise AppError(
ErrorCode.VALIDATION,
f"伏笔代号已存在:{body.code}",
{"field": "code", "code": body.code, "reason": "duplicate"},
) from exc
log.info(
"foreshadow_registered",
project_id=str(project_id),
request_id=request_id,
code=body.code,
)
return _to_view(view)
@router.get("/{project_id}/foreshadow")
async def list_foreshadow(
project_id: uuid.UUID,
repo: ForeshadowRepoDep,
status: str | None = None,
) -> ForeshadowBoardResponse:
"""伏笔看板:按 `status` 过滤(缺省=全部),按 code 升序。
`status` 非法(不在 OPEN/PARTIAL/CLOSED/OVERDUE→ VALIDATION 信封。四泳道前端
据 `status` 分组OVERDUE 泳道 + 逾期标记用 `expected_close_to`(看板字段已齐)。
"""
if status is not None and status not in {s.value for s in ForeshadowStatus}:
raise AppError(
ErrorCode.VALIDATION,
f"非法状态过滤:{status}",
{"field": "status", "reason": "invalid_status"},
)
views = await repo.list_by_status(project_id, status)
return ForeshadowBoardResponse(foreshadow=[_to_view(v) for v in views])
@router.patch("/{project_id}/foreshadow/{code}")
async def update_foreshadow(
project_id: uuid.UUID,
code: str,
body: ForeshadowTransitionRequest,
request: Request,
repo: ForeshadowRepoDep,
session: SessionDep,
) -> ForeshadowView:
"""状态机转移 和/或 追加进展。非法转移 → VALIDATION 信封;不存在 → NOT_FOUND。
`to_status` 与 `progress_entry` 皆可选;两者都缺 → VALIDATION无操作。先转移、后追加。
"""
request_id = getattr(request.state, "request_id", None)
if body.to_status is None and body.progress_entry is None:
raise AppError(
ErrorCode.VALIDATION,
"至少提供 `to_status` 或 `progress_entry` 之一",
{"reason": "empty_update"},
)
view: ForeshadowLedgerView
try:
if body.to_status is not None:
view = await repo.transition(project_id, code, to_status=body.to_status)
if body.progress_entry is not None:
view = await repo.record_progress(project_id, code, entry=body.progress_entry)
await session.commit()
except InvalidTransition as exc:
await session.rollback()
raise AppError(
ErrorCode.VALIDATION,
str(exc),
{"field": "to_status", "code": code, "reason": "invalid_transition"},
) from exc
except LookupError as exc:
await session.rollback()
raise AppError(ErrorCode.NOT_FOUND, f"foreshadow not found: {code}") from exc
log.info(
"foreshadow_updated",
project_id=str(project_id),
request_id=request_id,
code=code,
to_status=body.to_status,
has_progress=body.progress_entry is not None,
)
return _to_view(view)

View File

@@ -0,0 +1,135 @@
"""大纲生成端点C3 扩 / ARCH §5.4 outliner / §7.2 POST /outline不变量 #2/#3
POST /projects/:id/outline作者显式发起大纲生成 + 持久化。
流程:组确定性上下文(设定 + 已登记伏笔 + 人物 + 世界观)→ `run_outline`analyst 网关,
结构化 `OutlineResult`)→ 逐章 upsert `outline` 表 → 端点末尾一次 `commit()`。
提交边界:网关 ledger 只 flushrun_outline 产 1 条 usage、`OutlineWriteRepo.upsert_chapter`
只 flush → 端点末尾 `await session.commit()`(否则 usage_ledger + outline 行静默丢失,见 gotcha
不变量 #2经 `build_gateway_for_tier(..., "analyst")` 注入outliner_spec 只声明档位不传 model。
不变量 #3outliner `writes=["outline"]` 经此**作者发起**端点落地,非 AI 静默写其它表;
run_outline 节点本身只读不写库。无凭据 → `LLM_UNAVAILABLE`(友好提示,仿 draft/review
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import outliner_spec
from ww_core.domain import ForeshadowLedgerRepo, OutlineWriteRepo, ProjectRepo
from ww_core.domain.repositories import MemoryRepos
from ww_core.orchestrator import run_outline
from ww_db import get_session
from ww_llm_gateway import Gateway
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.outline import (
ForeshadowWindowView,
OutlineChapterView,
OutlineGenerateRequest,
OutlineResponse,
)
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.outline_context import build_outline_context
from ww_api.services.project_deps import (
get_foreshadow_repo,
get_memory_repos,
get_outline_gateway,
get_outline_write_repo,
get_project_repo,
)
log = get_logger("ww.api.outline")
router = APIRouter(prefix="/projects", tags=["outline"])
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)]
OutlineGatewayDep = Annotated[Gateway, Depends(get_outline_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@router.post("/{project_id}/outline")
async def generate_outline(
project_id: uuid.UUID,
body: OutlineGenerateRequest,
request: Request,
project_repo: ProjectRepoDep,
foreshadow_repo: ForeshadowRepoDep,
memory_repos: MemoryReposDep,
outline_repo: OutlineWriteRepoDep,
gateway: OutlineGatewayDep,
session: SessionDep,
) -> OutlineResponse:
"""生成大纲并逐章持久化。无凭据 → LLM_UNAVAILABLE项目不存在 → NOT_FOUND。"""
request_id = getattr(request.state, "request_id", None)
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
foreshadow = await foreshadow_repo.list_by_status(project_id, None)
characters = await memory_repos.character.list_for_project(project_id)
world_entities = await memory_repos.world_entity.list_for_project(project_id)
context = build_outline_context(
project=project,
foreshadow=foreshadow,
characters=characters,
world_entities=world_entities,
)
log.info(
"outline_generate_start",
project_id=str(project_id),
request_id=request_id,
volume=body.volume,
context_len=len(context),
foreshadow_count=len(foreshadow),
)
# run_outline 产结构化大纲analyst 网关;网关失败上抛,无凭据 → LLM_UNAVAILABLE
result = await run_outline(
outliner_spec,
context=context,
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
chapters: list[OutlineChapterView] = []
for chapter in result.chapters:
windows = [w.model_dump() for w in chapter.foreshadow_windows]
await outline_repo.upsert_chapter(
project_id,
volume=body.volume,
chapter_no=chapter.no,
beats=chapter.beats,
foreshadow_windows=windows,
)
chapters.append(
OutlineChapterView(
no=chapter.no,
volume=body.volume,
beats=list(chapter.beats),
foreshadow_windows=[ForeshadowWindowView(**w) for w in windows],
)
)
# 提交边界:网关 ledger + outline upsert 均只 flush → 端点末尾一次 commit。
await session.commit()
log.info(
"outline_generate_done",
project_id=str(project_id),
request_id=request_id,
chapter_count=len(chapters),
)
return OutlineResponse(chapters=chapters)

View File

@@ -17,7 +17,7 @@ import uuid
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain.chapter_repo import ChapterRepo
@@ -59,6 +59,7 @@ from ww_api.services.accept_service import (
)
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.digest_extraction import extract_digest_facts
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
from ww_api.services.project_deps import (
get_chapter_repo,
get_digest_append_repo,
@@ -67,6 +68,7 @@ from ww_api.services.project_deps import (
get_project_repo,
get_review_gateway,
get_review_repo,
get_session_factory,
get_writer_gateway,
)
@@ -302,14 +304,23 @@ async def accept_chapter(
chapter_no: int,
body: AcceptRequest,
request: Request,
background_tasks: BackgroundTasks,
chapter_repo: ChapterRepoDep,
digest_repo: DigestRepoDep,
review_repo: ReviewRepoDep,
gateway: DigestGatewayDep,
session: Annotated[AsyncSession, Depends(get_session)],
session_factory: Annotated[SessionFactory, Depends(get_session_factory)],
) -> AcceptResponse:
"""验收事务 + 冲突 gate§5.5gate事务前→ 终稿提炼 digest事务外R2
单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit
单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit → 登记**验收后伏笔到期扫描**
BackgroundTaskM3-b/d§6.2)。
到期扫描接 §5.5 步骤 3 的 `TODO(M3)` 占位:验收提交成功**后**,把
`current_ch > expected_close_to AND status≠CLOSED` 的伏笔确定性置 OVERDUE。
伏笔登记/回收PARTIAL/CLOSED的「采纳建议」经作者经 foreshadow 端点显式确认,
不在验收事务里 AI 静默改库(不变量 #3。**§7.4 持久性局限**BackgroundTask 进程内
跑、重启丢失——原型可接受(见 `services/foreshadow_scan.py`,不引入 jobs 表)。
"""
request_id = getattr(request.state, "request_id", None)
# R3审稿真相从领域表重读最近一条 chapter_reviews不依赖 checkpoint。
@@ -350,6 +361,17 @@ async def accept_chapter(
latest_review=latest_review,
decisions=body.decisions,
)
# 验收**提交成功后**登记伏笔到期扫描 BackgroundTaskM3-b/d。current_chapter=刚验收章号;
# 任务在请求 session 关闭后跑,故 run_overdue_scan 自建新 session 并 commit§7.4 局限可接受)。
background_tasks.add_task(
run_overdue_scan,
session_factory,
project_id=project_id,
chapter_no=chapter_no,
request_id=request_id,
)
return AcceptResponse(
project_id=project_id,
chapter_no=chapter_no,

View File

@@ -0,0 +1,66 @@
"""伏笔登记 / 状态变更端点的请求/响应 schemaC3 扩 / ARCH §6.2, §7.2)。
snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
登记是**作者显式动作**(不变量 #3伏笔入库不经 AI 静默写库),到期扫描是验收后的
确定性纯函数置位M3-d二者都不在审稿/写章流水线里。
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class ForeshadowRegisterRequest(BaseModel):
"""POST /projects/:id/foreshadow作者显式登记一条伏笔status 落 OPEN"""
code: str = Field(min_length=1, description="伏笔代号,`(project_id, code)` 唯一")
title: str = Field(min_length=1, description="伏笔标题/一句话描述")
planted_at: int | None = Field(default=None, description="埋设章号")
content: str | None = Field(default=None, description="伏笔正文/线索")
expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章")
expected_close_to: int | None = Field(
default=None, description="预期回收窗口结束章(到期判据用)"
)
importance: str | None = Field(default=None, description="重要度(自由文本,看板用)")
class ForeshadowTransitionRequest(BaseModel):
"""PATCH /projects/:id/foreshadow/:code状态机转移 和/或 追加一条进展。
两字段皆可选:`to_status` 走状态机(非法 → VALIDATION 信封);`progress_entry`
append-only 追加到 progress JSONB。二者可同时给先转移、后追加进展
"""
to_status: str | None = Field(
default=None, description="目标状态OPEN/PARTIAL/CLOSED/OVERDUE"
)
progress_entry: dict[str, Any] | None = Field(
default=None, description="追加一条进展记录append-only不覆盖历史"
)
class ForeshadowView(BaseModel):
"""伏笔账本视图(登记/状态变更/看板共用snake_case。形对齐 `ForeshadowLedgerView`。"""
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 ForeshadowBoardResponse(BaseModel):
"""GET /projects/:id/foreshadow?status=:伏笔看板(四泳道 + OVERDUE 字段齐)。
`status` 缺省返回全部;按 `code` 升序repo `list_by_status` 已排序)。前端按
`status` 分四泳道OPEN/PARTIAL/CLOSED/OVERDUE用 `expected_close_to` 标逾期。
"""
foreshadow: list[ForeshadowView] = Field(default_factory=list)

View File

@@ -0,0 +1,43 @@
"""大纲生成端点的请求/响应 schemaC3 扩 / ARCH §5.4, §7.2)。
snake_case前端经 OpenAPI 生成 TS 类型消费(改字段 → 前端 `pnpm gen:api`)。
大纲生成是**作者显式动作**(不变量 #3outliner `writes=["outline"]` 声明经此端点落地,
非 AI 静默写其它表)。窗口字段供前端 T3.6 渲染伏笔窗口徽标。
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class OutlineGenerateRequest(BaseModel):
"""POST /projects/:id/outline触发大纲生成 + 持久化。
M3 简单分卷:`volume` 缺省=1schema 无逐章卷号;本端点把整批章节落到同一卷)。
"""
volume: int = Field(default=1, ge=1, description="本批大纲所属卷号M3 默认全卷 1")
class ForeshadowWindowView(BaseModel):
"""单章关联的伏笔回收窗口snake_case形对齐 `ww_agents.ForeshadowWindow`)。"""
code: str
plant_chapter: int | None = None
expected_close_from: int | None = None
expected_close_to: int | None = None
class OutlineChapterView(BaseModel):
"""单章大纲视图:章号 + 卷 + 节拍 + 伏笔窗口。"""
no: int
volume: int
beats: list[str] = Field(default_factory=list)
foreshadow_windows: list[ForeshadowWindowView] = Field(default_factory=list)
class OutlineResponse(BaseModel):
"""大纲生成响应:逐章已持久化的大纲(含窗口,供前端 T3.6)。"""
chapters: list[OutlineChapterView] = Field(default_factory=list)

View File

@@ -114,9 +114,11 @@ async def run_accept_transaction(
)
review_id = updated.id
# 步骤 3(占位):人物 latest_state / 伏笔状态更新——M3 才正式接伏笔表
# 这里按 §5.5 步骤 3 留占位,不引入 M3 表逻辑(避免越界写未就绪的状态机)。
# TODO(M3): 按裁决应用 latest_state 变更 + 伏笔登记/到期扫描§6.2)。
# 步骤 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 同形)

View File

@@ -0,0 +1,90 @@
"""验收后伏笔到期扫描M3-b/dARCH §6.2, §7.4,不变量 #3
接 §5.5 验收事务后的 `TODO(M3)` 占位:验收**提交成功后**触发一次伏笔到期扫描,把
`current_ch > expected_close_to AND status≠CLOSED` 的伏笔确定性置 `OVERDUE`M3-d 纯
函数,非 Agent不变量 #3状态变更是确定性扫描而非 AI 静默写库)。
挂载方式端点FastAPI `BackgroundTasks` 在 accept 端点登记 `run_overdue_scan`。
BackgroundTask 在请求-response 发回、请求 session 关闭**之后**才跑——故本函数**自建新
session**(不复用请求 session并自己 `commit()`。
**持久性局限§7.4**BackgroundTasks 在 API 进程内跑,进程重启/崩溃会丢失未跑完的
任务——原型可接受,这里把触发/完成/失败都记结构化日志便于查错;**不引入 `jobs` 表**
(那是 M4/T4.1 的工作,本任务不越界)。
可测性:扫描逻辑抽成纯 async 函数 `run_overdue_scan(session_factory, ...)`,端点用
BackgroundTasks 调它;单测直接 `await` 它(注入 fake session 工厂)断言行被置 OVERDUE
**绝不真起不可控后台线程**。
"""
from __future__ import annotations
import uuid
from collections.abc import Callable
from contextlib import AbstractAsyncContextManager
from typing import Protocol
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain import ForeshadowLedgerView, SqlForeshadowLedgerRepo
log = structlog.get_logger(__name__)
# 新建独立 session 的工厂:调用得到一个 async 上下文管理器(`async with` → AsyncSession
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
class OverdueScanRepo(Protocol):
"""到期扫描对账本 repo 的最小依赖(仅 `scan_overdue`)。"""
async def scan_overdue(
self, project_id: uuid.UUID, *, current_chapter: int
) -> list[ForeshadowLedgerView]: ...
# repo 工厂:从新 session 造账本 repo。默认建 SQL 实现;测试注入 fake避免真连 DB
RepoFactory = Callable[[AsyncSession], OverdueScanRepo]
def _default_repo_factory(session: AsyncSession) -> OverdueScanRepo:
return SqlForeshadowLedgerRepo(session)
async def run_overdue_scan(
session_factory: SessionFactory,
*,
project_id: uuid.UUID,
chapter_no: int,
request_id: str | None = None,
repo_factory: RepoFactory = _default_repo_factory,
) -> int:
"""验收后伏笔到期扫描:新建 session → `scan_overdue` 置 OVERDUE → `commit()`。
返回被置 OVERDUE 的伏笔条数(供日志/测试断言)。任何异常被捕获并记错误日志后
吞掉——后台任务失败不应冒泡崩进程也不影响已成功的验收事务§7.4 局限可接受)。
`repo_factory` 是可注入缝:默认建 SQL 账本 repo测试注 fake直接 await、不联网
"""
try:
async with session_factory() as session:
repo = repo_factory(session)
changed = await repo.scan_overdue(project_id, current_chapter=chapter_no)
if changed:
await session.commit()
log.info(
"foreshadow_overdue_scan",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
overdue_count=len(changed),
overdue_codes=[v.code for v in changed],
)
return len(changed)
except Exception as exc: # noqa: BLE001 — 后台任务边界记错误、不冒泡崩进程§7.4)。
log.error(
"foreshadow_overdue_scan_failed",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
error=str(exc),
)
return 0

View File

@@ -0,0 +1,87 @@
"""大纲生成的注入上下文组装确定性序列化文本ARCH §5.4 outliner reads / §6.2)。
把 projects 设定 + 已登记伏笔 + 人物 + 世界观确定性序列化成纯文本喂给 outliner——
**无时间戳/无 UUID**(缓存前缀稳定,不变量 #9逐项排序保证同输入同输出可单测
outliner 据此排出分章节拍 + 伏笔回收窗口关联本章与伏笔§6.2)。
reads=["projects","foreshadow","characters","world_entities"]C6 outliner_spec
本组装恰好覆盖这四源,不读 digest/outline排大纲是从设定生成、非续写既有章
"""
from __future__ import annotations
from collections.abc import Sequence
from ww_core.domain import ForeshadowLedgerView, ProjectView
from ww_core.domain.repositories import CharacterView, WorldEntityView
def _project_block(project: ProjectView) -> str:
lines = [f"标题:{project.title}"]
if project.genre:
lines.append(f"类型:{project.genre}")
if project.logline:
lines.append(f"一句话简介:{project.logline}")
if project.premise:
lines.append(f"前提:{project.premise}")
if project.theme:
lines.append(f"主题:{project.theme}")
if project.selling_points:
lines.append("卖点:" + "".join(project.selling_points))
if project.structure:
lines.append(f"结构:{project.structure}")
return "【作品设定】\n" + "\n".join(lines)
def _foreshadow_block(foreshadow: Sequence[ForeshadowLedgerView]) -> str:
if not foreshadow:
return "【已登记伏笔】\n(无)"
rows = sorted(foreshadow, key=lambda f: f.code)
lines = []
for f in rows:
window = ""
if f.expected_close_from is not None or f.expected_close_to is not None:
lo = f.expected_close_from if f.expected_close_from is not None else "?"
hi = f.expected_close_to if f.expected_close_to is not None else "?"
window = f"(回收窗口 {lo}-{hi}"
plant = f"埋设第{f.planted_at}" if f.planted_at is not None else ""
lines.append(f"- [{f.code}] {f.title} {plant}{window}".rstrip())
return "【已登记伏笔】\n" + "\n".join(lines)
def _characters_block(characters: Sequence[CharacterView]) -> str:
if not characters:
return "【人物】\n(无)"
rows = sorted(characters, key=lambda c: c.name)
lines = []
for c in rows:
role = f"{c.role}" if c.role else ""
motive = f" 动机:{c.motive}" if c.motive else ""
lines.append(f"- {c.name}{role}{motive}".rstrip())
return "【人物】\n" + "\n".join(lines)
def _world_block(entities: Sequence[WorldEntityView]) -> str:
if not entities:
return "【世界观】\n(无)"
rows = sorted(entities, key=lambda e: (e.type, e.name))
lines = [f"- [{e.type}] {e.name}" for e in rows]
return "【世界观】\n" + "\n".join(lines)
def build_outline_context(
*,
project: ProjectView,
foreshadow: Sequence[ForeshadowLedgerView],
characters: Sequence[CharacterView],
world_entities: Sequence[WorldEntityView],
) -> str:
"""组装 outliner 注入上下文(确定性文本,纯函数)。"""
return "\n\n".join(
[
_project_block(project),
_foreshadow_block(foreshadow),
_characters_block(characters),
_world_block(world_entities),
]
)

View File

@@ -16,13 +16,15 @@ from openai import AsyncOpenAI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo
from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo
from ww_core.domain.repositories import MemoryRepos
from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo
from ww_core.memory.sql_repositories import sql_memory_repos
from ww_db import get_session
from ww_db import get_session, get_sessionmaker
from ww_db.models import User
from ww_llm_gateway import (
Gateway,
@@ -42,6 +44,7 @@ from ww_api.services.credentials import (
CredentialStore,
SqlCredentialStore,
)
from ww_api.services.foreshadow_scan import SessionFactory
from ww_api.services.provider_deps import _PROVIDER_BASE_URLS
# 单用户 stub 的占位邮箱(多租户化时由真实主体替换)。
@@ -95,6 +98,30 @@ def get_digest_append_repo(
return SqlDigestAppendRepo(session)
def get_foreshadow_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> ForeshadowLedgerRepo:
"""伏笔账本写侧 repo登记/状态变更端点;只 flush端点提交。测试经 override 注 fake。"""
return SqlForeshadowLedgerRepo(session)
def get_outline_write_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> OutlineWriteRepo:
"""大纲写侧 repo大纲生成端点逐章 upsert只 flush端点提交。测试经 override 注 fake。"""
return SqlOutlineWriteRepo(session)
def get_session_factory() -> SessionFactory:
"""验收后到期扫描的**独立 session 工厂**缝。
BackgroundTask 在请求 session 关闭后才跑,必须自建 session不复用 `get_session`)。
返回的工厂 `()` → `async with` 得一个新 `AsyncSession`。测试经 `app.dependency_overrides`
注入 fake 工厂(避免真起后台线程/真连 DB
"""
return get_sessionmaker()
async def build_gateway_for_tier(
session: AsyncSession, store: CredentialStore, tier: Tier
) -> Gateway:
@@ -160,3 +187,11 @@ async def get_digest_gateway(
"""验收终稿 digest 提炼light 档位)的可注入网关缝。测试经 override 注 mock。"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "light")
async def get_outline_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""大纲生成analyst 档位)的可注入网关缝。测试经 override 注 mock产 OutlineResult"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")