feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。 M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变); 网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则; 前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。 K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径; coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。 本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400); GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化; 字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。 门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
375
packages/core/tests/test_generation_node.py
Normal file
375
packages/core/tests/test_generation_node.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""T5.1 生成节点单测:worldbuilder + character-gen + 入库前 continuity 校验缝。
|
||||
|
||||
注入 mock 网关(产 WorldGenResult / CharacterGenResult / ContinuityReview parsed),
|
||||
无真 LLM、无真 Postgres。生成节点是独立生成(不在写章/审稿流水线),裸函数;
|
||||
只产结构化产物,**不写库**(持久化属 T5.2 入库端点,不变量 #3)。asyncio_mode=auto。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fakes_orchestrator import FailingRunGateway, FakeRunGateway, SchemaRoutingRunGateway
|
||||
from ww_agents import (
|
||||
CharacterCard,
|
||||
CharacterGenResult,
|
||||
CharacterRelation,
|
||||
Conflict,
|
||||
ContinuityReview,
|
||||
WorldEntityCard,
|
||||
WorldGenResult,
|
||||
character_gen_spec,
|
||||
continuity_spec,
|
||||
worldbuilder_spec,
|
||||
)
|
||||
from ww_core.orchestrator import (
|
||||
build_character_gen_context,
|
||||
build_precheck_context,
|
||||
precheck_generated_cards,
|
||||
run_character_gen,
|
||||
run_worldbuilder,
|
||||
)
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
_WORLD = WorldGenResult(
|
||||
entities=[
|
||||
WorldEntityCard(type="力量体系", name="九转炼气", rules=["逐境突破不可越级"]),
|
||||
]
|
||||
)
|
||||
|
||||
_CARDS = CharacterGenResult(
|
||||
cards=[
|
||||
CharacterCard(
|
||||
name="苏黎",
|
||||
role="对手",
|
||||
traits=["表面冷漠"],
|
||||
backstory="出身敌对势力",
|
||||
arc="复仇 → 救赎",
|
||||
speech_tics=["惯用反问"],
|
||||
tags=["亦正亦邪"],
|
||||
relations=[CharacterRelation(name="主角", kind="宿敌")],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---- run_worldbuilder:返回结构化世界观,只读不写库 ----
|
||||
|
||||
|
||||
async def test_run_worldbuilder_returns_structured_entities() -> None:
|
||||
gateway = FakeRunGateway(_WORLD)
|
||||
|
||||
result = await run_worldbuilder(
|
||||
worldbuilder_spec,
|
||||
brief="设计一个修真世界的力量体系",
|
||||
project_context="题材:修真;立意:逆袭",
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
assert isinstance(result, WorldGenResult)
|
||||
assert result.entities[0].name == "九转炼气"
|
||||
assert result.entities[0].rules == ["逐境突破不可越级"]
|
||||
assert gateway.call_count == 1
|
||||
|
||||
|
||||
async def test_run_worldbuilder_forwards_writer_tier_request() -> None:
|
||||
gateway = FakeRunGateway(_WORLD)
|
||||
|
||||
await run_worldbuilder(
|
||||
worldbuilder_spec,
|
||||
brief="设计力量体系",
|
||||
project_context="题材:修真",
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
req = gateway.last_request
|
||||
assert req is not None
|
||||
assert req.tier == "writer" # 不变量②:spec 档位透传
|
||||
assert req.stream is False # 生成用 run() 非 stream()
|
||||
assert req.output_schema is WorldGenResult
|
||||
assert req.system[0].cache is True # 不变量#9
|
||||
assert "设计力量体系" in req.input
|
||||
assert "题材:修真" in req.input
|
||||
|
||||
|
||||
async def test_run_worldbuilder_raises_when_gateway_fails() -> None:
|
||||
# 独立生成:网关失败直接上抛(端点/T5.2 处理),不静默吞。
|
||||
gateway = FailingRunGateway(RuntimeError("provider down"))
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await run_worldbuilder(
|
||||
worldbuilder_spec,
|
||||
brief="x",
|
||||
project_context="y",
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
|
||||
async def test_run_worldbuilder_raises_when_parsed_missing() -> 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-writer",
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
cost_minor=0,
|
||||
currency="USD",
|
||||
),
|
||||
served_by=ServedBy(provider="fake", model="fake-writer"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="parsed"):
|
||||
await run_worldbuilder(
|
||||
worldbuilder_spec,
|
||||
brief="x",
|
||||
project_context="y",
|
||||
gateway=_NoParseGateway(), # type: ignore[arg-type]
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
|
||||
# ---- build_character_gen_context:群像防雷同(注入已有 + 已生成)----
|
||||
|
||||
|
||||
def test_build_character_gen_context_injects_brief_count_role() -> None:
|
||||
ctx = build_character_gen_context(
|
||||
brief="给我一个亦正亦邪的女二",
|
||||
count=3,
|
||||
role="CP",
|
||||
world_context="力量体系:九转炼气(逐境突破)",
|
||||
existing_chars=[],
|
||||
generated_so_far=[],
|
||||
)
|
||||
|
||||
assert "给我一个亦正亦邪的女二" in ctx
|
||||
assert "3" in ctx
|
||||
assert "CP" in ctx
|
||||
assert "九转炼气" in ctx
|
||||
|
||||
|
||||
def test_build_character_gen_context_injects_existing_and_generated() -> None:
|
||||
# M5-a 防雷同:已有角色 + 本批已生成卡都进上下文,要求差异化
|
||||
existing = [CharacterCard(name="李白", role="主角", backstory="b", arc="a")]
|
||||
generated = [CharacterCard(name="苏黎", role="对手", backstory="b2", arc="a2")]
|
||||
|
||||
ctx = build_character_gen_context(
|
||||
brief="再来一个配角",
|
||||
count=1,
|
||||
role=None,
|
||||
world_context="",
|
||||
existing_chars=existing,
|
||||
generated_so_far=generated,
|
||||
)
|
||||
|
||||
assert "李白" in ctx # 已有角色
|
||||
assert "苏黎" in ctx # 本批已生成卡
|
||||
|
||||
|
||||
def test_build_character_gen_context_handles_empty_anti_dup() -> None:
|
||||
# 首次单生成:无已有/已生成时仍产出可用上下文(不崩)
|
||||
ctx = build_character_gen_context(
|
||||
brief="主角",
|
||||
count=1,
|
||||
role="主角",
|
||||
world_context="",
|
||||
existing_chars=[],
|
||||
generated_so_far=[],
|
||||
)
|
||||
assert "主角" in ctx
|
||||
|
||||
|
||||
# ---- run_character_gen:返回结构化群像,注入防雷同上下文 ----
|
||||
|
||||
|
||||
async def test_run_character_gen_returns_structured_cards() -> None:
|
||||
gateway = FakeRunGateway(_CARDS)
|
||||
|
||||
result = await run_character_gen(
|
||||
character_gen_spec,
|
||||
brief="亦正亦邪的女二",
|
||||
count=1,
|
||||
role="对手",
|
||||
world_context="力量体系:九转炼气",
|
||||
existing_chars=[],
|
||||
generated_so_far=[],
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
assert isinstance(result, CharacterGenResult)
|
||||
assert result.cards[0].name == "苏黎"
|
||||
assert gateway.call_count == 1
|
||||
|
||||
|
||||
async def test_run_character_gen_injects_anti_dup_into_request() -> None:
|
||||
existing = [CharacterCard(name="李白", role="主角", backstory="b", arc="a")]
|
||||
gateway = FakeRunGateway(_CARDS)
|
||||
|
||||
await run_character_gen(
|
||||
character_gen_spec,
|
||||
brief="配角",
|
||||
count=2,
|
||||
role=None,
|
||||
world_context="九转炼气",
|
||||
existing_chars=existing,
|
||||
generated_so_far=[],
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
req = gateway.last_request
|
||||
assert req is not None
|
||||
assert req.tier == "writer"
|
||||
assert req.output_schema is CharacterGenResult
|
||||
assert "李白" in req.input # 已有角色注入(防雷同)
|
||||
assert "九转炼气" in req.input # 世界观约束注入
|
||||
|
||||
|
||||
async def test_run_character_gen_raises_when_gateway_fails() -> None:
|
||||
gateway = FailingRunGateway(RuntimeError("provider down"))
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await run_character_gen(
|
||||
character_gen_spec,
|
||||
brief="x",
|
||||
count=1,
|
||||
role=None,
|
||||
world_context="",
|
||||
existing_chars=[],
|
||||
generated_so_far=[],
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
|
||||
# ---- precheck_generated_cards:入库前 continuity 校验缝(ARCH §6.5)----
|
||||
|
||||
|
||||
def test_build_precheck_context_includes_cards_and_truth() -> None:
|
||||
ctx = build_precheck_context(
|
||||
cards=_CARDS.cards,
|
||||
world_context="力量体系:九转炼气(逐境突破不可越级)",
|
||||
characters_context="主角:李白",
|
||||
)
|
||||
|
||||
assert "苏黎" in ctx # 待校验角色卡
|
||||
assert "九转炼气" in ctx # 世界观真相源
|
||||
assert "李白" in ctx # 已有角色真相源
|
||||
|
||||
|
||||
async def test_precheck_returns_conflicts_when_card_violates_world() -> None:
|
||||
# 编排器入库前追加 continuity 校验:角色能力越级 → 检出设定违例冲突
|
||||
review = ContinuityReview(
|
||||
conflicts=[
|
||||
Conflict(
|
||||
type="设定违例",
|
||||
where="苏黎背景:天生跨境界",
|
||||
refs=["九转炼气:逐境突破不可越级"],
|
||||
suggestion="改为逐境修炼",
|
||||
)
|
||||
]
|
||||
)
|
||||
gateway = FakeRunGateway(review)
|
||||
|
||||
conflicts = await precheck_generated_cards(
|
||||
continuity_spec,
|
||||
cards=_CARDS.cards,
|
||||
world_context="力量体系:九转炼气(逐境突破不可越级)",
|
||||
characters_context="",
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
assert len(conflicts) == 1
|
||||
assert conflicts[0].type == "设定违例"
|
||||
assert gateway.call_count == 1
|
||||
# 校验用 continuity 档位(analyst),且只读
|
||||
req = gateway.last_request
|
||||
assert req is not None
|
||||
assert req.tier == "analyst"
|
||||
assert req.output_schema is ContinuityReview
|
||||
|
||||
|
||||
async def test_precheck_returns_empty_when_no_conflict() -> None:
|
||||
gateway = FakeRunGateway(ContinuityReview(conflicts=[]))
|
||||
|
||||
conflicts = await precheck_generated_cards(
|
||||
continuity_spec,
|
||||
cards=_CARDS.cards,
|
||||
world_context="力量体系:九转炼气",
|
||||
characters_context="",
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
assert conflicts == []
|
||||
|
||||
|
||||
async def test_precheck_uses_continuity_schema_routing() -> None:
|
||||
# 校验缝复用 continuity_spec → 网关按 output_schema 路由产 ContinuityReview
|
||||
gateway = SchemaRoutingRunGateway({ContinuityReview: ContinuityReview(conflicts=[])})
|
||||
|
||||
conflicts = await precheck_generated_cards(
|
||||
continuity_spec,
|
||||
cards=_CARDS.cards,
|
||||
world_context="w",
|
||||
characters_context="c",
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
assert conflicts == []
|
||||
assert gateway.calls == [ContinuityReview]
|
||||
|
||||
|
||||
async def test_precheck_raises_when_parsed_missing() -> 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 precheck_generated_cards(
|
||||
continuity_spec,
|
||||
cards=_CARDS.cards,
|
||||
world_context="w",
|
||||
characters_context="c",
|
||||
gateway=_NoParseGateway(), # type: ignore[arg-type]
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
31
packages/core/tests/test_generation_write_repos.py
Normal file
31
packages/core/tests/test_generation_write_repos.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""T5.2 写侧 repo 的 schema→DB 形变(纯函数 helper,无 DB)。
|
||||
|
||||
DB 行的实际落库由 apps/api 端点测试(fake repo)+ T5.7 E2E(真 pg)覆盖;
|
||||
这里只钉住 list→dict / str→dict 的转换契约(T5.1 gotcha),防回归。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ww_core.domain.character_repo import _arc_to_jsonb, _traits_to_jsonb
|
||||
from ww_core.domain.world_entity_repo import _rules_to_jsonb
|
||||
|
||||
|
||||
def test_traits_list_wraps_into_jsonb_dict() -> None:
|
||||
# Arrange
|
||||
traits = ["腹黑", "护短"]
|
||||
# Act
|
||||
out = _traits_to_jsonb(traits)
|
||||
# Assert:DB 列是 JSONB dict,list 包成 {"items":[...]}。
|
||||
assert out == {"items": ["腹黑", "护短"]}
|
||||
|
||||
|
||||
def test_traits_empty_list_still_dict_shape() -> None:
|
||||
assert _traits_to_jsonb([]) == {"items": []}
|
||||
|
||||
|
||||
def test_arc_str_wraps_into_jsonb_dict() -> None:
|
||||
assert _arc_to_jsonb("从孤儿到帝王") == {"text": "从孤儿到帝王"}
|
||||
|
||||
|
||||
def test_rules_list_wraps_into_jsonb_dict() -> None:
|
||||
assert _rules_to_jsonb(["禁止跨界传送", "灵力守恒"]) == {"rules": ["禁止跨界传送", "灵力守恒"]}
|
||||
243
packages/core/tests/test_job_repo.py
Normal file
243
packages/core/tests/test_job_repo.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""T4.1 长任务 `jobs` 写侧 repo 单测(ARCH §7.4)。
|
||||
|
||||
纯内存 fake(镜像 `SqlJobRepo` 语义),无 DB:断言状态流转 queued→running→done/failed、
|
||||
progress 夹取、result/error 落位、`reap_zombies` 把 running→failed、frozen View 不可变。
|
||||
真实 DB 路径(含 `reap_zombies` 自 commit)由 T4.5 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.job_repo import (
|
||||
PROGRESS_COMPLETE,
|
||||
STATUS_DONE,
|
||||
STATUS_FAILED,
|
||||
STATUS_QUEUED,
|
||||
STATUS_RUNNING,
|
||||
JobRepo,
|
||||
JobView,
|
||||
)
|
||||
|
||||
KIND = "style_learn"
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Row:
|
||||
id: uuid.UUID
|
||||
kind: str
|
||||
status: str
|
||||
progress: int
|
||||
result: dict[str, Any] | None
|
||||
error: str | None
|
||||
|
||||
|
||||
def _view(row: _Row) -> JobView:
|
||||
return JobView(
|
||||
id=row.id,
|
||||
kind=row.kind,
|
||||
status=row.status,
|
||||
progress=row.progress,
|
||||
result=row.result,
|
||||
error=row.error,
|
||||
)
|
||||
|
||||
|
||||
def _clamp(pct: int) -> int:
|
||||
return max(0, min(PROGRESS_COMPLETE, pct))
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeJobRepo:
|
||||
"""内存替身——镜像 SqlJobRepo 语义(状态流转 + 进度夹取 + 僵尸回收)。"""
|
||||
|
||||
rows: list[_Row] = field(default_factory=list)
|
||||
|
||||
def _require(self, job_id: uuid.UUID) -> _Row:
|
||||
row = next((r for r in self.rows if r.id == job_id), None)
|
||||
if row is None:
|
||||
raise LookupError(f"job not found: {job_id}")
|
||||
return row
|
||||
|
||||
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
|
||||
row = _Row(
|
||||
id=uuid.uuid4(),
|
||||
kind=kind,
|
||||
status=STATUS_QUEUED,
|
||||
progress=0,
|
||||
result=None,
|
||||
error=None,
|
||||
)
|
||||
self.rows.append(row)
|
||||
return _view(row)
|
||||
|
||||
async def set_running(self, job_id: uuid.UUID) -> JobView:
|
||||
row = self._require(job_id)
|
||||
row.status = STATUS_RUNNING
|
||||
return _view(row)
|
||||
|
||||
async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView:
|
||||
row = self._require(job_id)
|
||||
row.progress = _clamp(pct)
|
||||
return _view(row)
|
||||
|
||||
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
|
||||
row = self._require(job_id)
|
||||
row.status = STATUS_DONE
|
||||
row.progress = PROGRESS_COMPLETE
|
||||
row.result = dict(result)
|
||||
return _view(row)
|
||||
|
||||
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
|
||||
row = self._require(job_id)
|
||||
row.status = STATUS_FAILED
|
||||
row.error = error
|
||||
return _view(row)
|
||||
|
||||
async def get(self, job_id: uuid.UUID) -> JobView | None:
|
||||
row = next((r for r in self.rows if r.id == job_id), None)
|
||||
return _view(row) if row is not None else None
|
||||
|
||||
async def reap_zombies(self) -> int:
|
||||
changed = 0
|
||||
for r in self.rows:
|
||||
if r.status == STATUS_RUNNING:
|
||||
r.status = STATUS_FAILED
|
||||
r.error = "job interrupted (process restart)"
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
def _repo() -> FakeJobRepo:
|
||||
return FakeJobRepo()
|
||||
|
||||
|
||||
# ---- create ----
|
||||
|
||||
|
||||
async def test_create_starts_queued() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
view = await repo.create(PROJECT, KIND)
|
||||
assert isinstance(view, JobView)
|
||||
assert view.kind == KIND
|
||||
assert view.status == STATUS_QUEUED
|
||||
assert view.progress == 0
|
||||
assert view.result is None
|
||||
assert view.error is None
|
||||
|
||||
|
||||
async def test_create_allows_null_project() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
view = await repo.create(None, KIND)
|
||||
assert view.status == STATUS_QUEUED
|
||||
|
||||
|
||||
# ---- state transitions ----
|
||||
|
||||
|
||||
async def test_set_running_marks_running() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
job = await repo.create(PROJECT, KIND)
|
||||
running = await repo.set_running(job.id)
|
||||
assert running.status == STATUS_RUNNING
|
||||
|
||||
|
||||
async def test_complete_sets_done_full_progress_and_result() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
job = await repo.create(PROJECT, KIND)
|
||||
await repo.set_running(job.id)
|
||||
done = await repo.complete(job.id, {"version": 2, "dims_count": 16})
|
||||
assert done.status == STATUS_DONE
|
||||
assert done.progress == PROGRESS_COMPLETE
|
||||
assert done.result == {"version": 2, "dims_count": 16}
|
||||
|
||||
|
||||
async def test_fail_sets_failed_and_error() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
job = await repo.create(PROJECT, KIND)
|
||||
await repo.set_running(job.id)
|
||||
failed = await repo.fail(job.id, "boom")
|
||||
assert failed.status == STATUS_FAILED
|
||||
assert failed.error == "boom"
|
||||
|
||||
|
||||
# ---- progress (clamped) ----
|
||||
|
||||
|
||||
async def test_set_progress_updates_pct() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
job = await repo.create(PROJECT, KIND)
|
||||
v = await repo.set_progress(job.id, 42)
|
||||
assert v.progress == 42
|
||||
|
||||
|
||||
async def test_set_progress_clamps_out_of_range() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
job = await repo.create(PROJECT, KIND)
|
||||
assert (await repo.set_progress(job.id, 250)).progress == PROGRESS_COMPLETE
|
||||
assert (await repo.set_progress(job.id, -5)).progress == 0
|
||||
|
||||
|
||||
# ---- get ----
|
||||
|
||||
|
||||
async def test_get_returns_none_when_absent() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
assert await repo.get(uuid.uuid4()) is None
|
||||
|
||||
|
||||
async def test_get_returns_current_state() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
job = await repo.create(PROJECT, KIND)
|
||||
await repo.complete(job.id, {"ok": True})
|
||||
fetched = await repo.get(job.id)
|
||||
assert fetched is not None
|
||||
assert fetched.status == STATUS_DONE
|
||||
|
||||
|
||||
async def test_missing_job_raises_on_write() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
with pytest.raises(LookupError):
|
||||
await repo.set_running(uuid.uuid4())
|
||||
|
||||
|
||||
# ---- reap_zombies ----
|
||||
|
||||
|
||||
async def test_reap_zombies_marks_running_failed() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
queued = await repo.create(PROJECT, KIND)
|
||||
running = await repo.create(PROJECT, KIND)
|
||||
await repo.set_running(running.id)
|
||||
done = await repo.create(PROJECT, KIND)
|
||||
await repo.complete(done.id, {})
|
||||
|
||||
count = await repo.reap_zombies()
|
||||
|
||||
assert count == 1
|
||||
reaped = await repo.get(running.id)
|
||||
assert reaped is not None and reaped.status == STATUS_FAILED
|
||||
# queued / done 不受影响
|
||||
assert (await repo.get(queued.id)).status == STATUS_QUEUED # type: ignore[union-attr]
|
||||
assert (await repo.get(done.id)).status == STATUS_DONE # type: ignore[union-attr]
|
||||
|
||||
|
||||
async def test_reap_zombies_noop_when_no_running() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
await repo.create(PROJECT, KIND)
|
||||
assert await repo.reap_zombies() == 0
|
||||
|
||||
|
||||
# ---- frozen View ----
|
||||
|
||||
|
||||
async def test_view_is_frozen() -> None:
|
||||
repo: JobRepo = _repo()
|
||||
v = await repo.create(PROJECT, KIND)
|
||||
with pytest.raises(ValidationError):
|
||||
v.status = STATUS_DONE
|
||||
@@ -14,6 +14,7 @@ from ww_core.domain.repositories import (
|
||||
ForeshadowView,
|
||||
MemoryRepos,
|
||||
OutlineView,
|
||||
ProjectSpecView,
|
||||
RuleView,
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
@@ -41,6 +42,9 @@ class FakeOutlineRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return self.rows.get(chapter_no)
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
return [self.rows[k] for k in sorted(self.rows)]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeCharacterRepo:
|
||||
@@ -91,6 +95,14 @@ class FakeRulesRepo:
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeProjectSpecRepo:
|
||||
row: ProjectSpecView | None = None
|
||||
|
||||
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
|
||||
return self.row
|
||||
|
||||
|
||||
def build_repos(
|
||||
*,
|
||||
outline: dict[int, OutlineView] | None = None,
|
||||
@@ -100,6 +112,7 @@ def build_repos(
|
||||
foreshadows: list[ForeshadowView] | None = None,
|
||||
style: StyleView | None = None,
|
||||
rules: list[RuleView] | None = None,
|
||||
spec: ProjectSpecView | None = None,
|
||||
) -> MemoryRepos:
|
||||
return MemoryRepos(
|
||||
outline=FakeOutlineRepo(outline or {}),
|
||||
@@ -109,6 +122,7 @@ def build_repos(
|
||||
foreshadow=FakeForeshadowRepo(foreshadows or []),
|
||||
style=FakeStyleRepo(style),
|
||||
rules=FakeRulesRepo(rules or []),
|
||||
project=FakeProjectSpecRepo(spec),
|
||||
)
|
||||
|
||||
|
||||
@@ -244,6 +258,26 @@ async def test_rule_merge_precedence() -> None:
|
||||
assert g < ge < s < p
|
||||
|
||||
|
||||
async def test_bare_project_premise_only_yields_nonempty_prompt() -> None:
|
||||
# 全新项目:只有书级蓝本(premise/logline/title),无世界观/角色/大纲/伏笔。
|
||||
spec = ProjectSpecView(title="无名之书", logline="一个开始", premise="末世孤舟漂流")
|
||||
repos = build_repos(spec=spec) # 其余全空
|
||||
ctx = await assemble(repos, PROJECT, 1)
|
||||
# stable_core 非空且含书级蓝本
|
||||
assert ctx.stable_core != ""
|
||||
assert "末世孤舟漂流" in ctx.stable_core
|
||||
assert "无名之书" in ctx.stable_core
|
||||
# volatile 非空且含本章写作指令 → writer 的 user message 永不为空
|
||||
assert ctx.volatile != ""
|
||||
assert "请创作第 1 章的正文。" in ctx.volatile
|
||||
|
||||
|
||||
async def test_chapter_directive_uses_chapter_no() -> None:
|
||||
repos = build_repos(spec=ProjectSpecView(title="书"))
|
||||
ctx = await assemble(repos, PROJECT, 7)
|
||||
assert "请创作第 7 章的正文。" in ctx.volatile
|
||||
|
||||
|
||||
# ---- helpers ----
|
||||
|
||||
|
||||
@@ -267,4 +301,5 @@ def _full_repos() -> MemoryRepos:
|
||||
foreshadows=[ForeshadowView(code="F1", title="神秘石符", status="OPEN", planted_at=1)],
|
||||
style=StyleView(dimensions={"句长": "短句为主"}),
|
||||
rules=[RuleView(level="global", content="全局规则")],
|
||||
spec=ProjectSpecView(title="大梦主", logline="少年逆天改命", premise="灵气复苏"),
|
||||
)
|
||||
|
||||
46
packages/core/tests/test_rules_repo.py
Normal file
46
packages/core/tests/test_rules_repo.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""T5.5 规则写侧 repo 单测(C3 扩 / PRODUCT_SPEC §7 POST /rules)。
|
||||
|
||||
`RuleWriteRepo.create` 插一行 `rules`(level + content,绑 project_id),只 flush 不 commit
|
||||
(提交交端点事务,与项目其它写侧 repo 一致)。`level` 合法性(global/genre/style/project)
|
||||
由端点/schema 校验;repo 只负责写。纯内存 fake,无 DB。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from ww_core.domain.rule_repo import RuleWriteRepo, RuleWriteView
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeRuleWriteRepo:
|
||||
rows: list[RuleWriteView] = field(default_factory=list)
|
||||
flushed: int = 0
|
||||
|
||||
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
|
||||
view = RuleWriteView(project_id=project_id, level=level, content=content)
|
||||
self.rows.append(view)
|
||||
self.flushed += 1
|
||||
return view
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_returns_view() -> None:
|
||||
repo: RuleWriteRepo = _FakeRuleWriteRepo()
|
||||
|
||||
view = await repo.create(PROJECT, level="project", content="主角不许复活")
|
||||
|
||||
assert view.level == "project"
|
||||
assert view.content == "主角不许复活"
|
||||
assert view.project_id == PROJECT
|
||||
|
||||
|
||||
def test_rule_view_is_frozen() -> None:
|
||||
view = RuleWriteView(project_id=PROJECT, level="global", content="x")
|
||||
with pytest.raises(ValidationError):
|
||||
view.content = "y"
|
||||
137
packages/core/tests/test_style_extract_node.py
Normal file
137
packages/core/tests/test_style_extract_node.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""T4.2 文风提取节点单测——build_style_extract_request + run_style_extraction(C6 扩 / ARCH §5.4)。
|
||||
|
||||
注入 mock 网关(产 `StyleFingerprintResult` parsed),无真 LLM、无真 Postgres。
|
||||
提取节点是裸函数(独立生成、仿 outliner,不在写章/审稿流水线);只产结构化指纹,**不写库**
|
||||
(持久化属 T4.3 端点,不变量 #3)。asyncio_mode=auto。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fakes_orchestrator import FailingRunGateway, FakeRunGateway
|
||||
from ww_agents import (
|
||||
StyleDimension,
|
||||
StyleFingerprintResult,
|
||||
style_extract_spec,
|
||||
)
|
||||
from ww_core.orchestrator import build_style_extract_request, run_style_extraction
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
SAMPLES = "样本一:他一刀劈下,血溅三尺。\n样本二:风停了,只剩呼吸声。"
|
||||
|
||||
_FINGERPRINT = StyleFingerprintResult(
|
||||
dimensions=[
|
||||
StyleDimension(
|
||||
name="句长节奏",
|
||||
value="短句为主、节奏明快",
|
||||
evidence=["他一刀劈下,血溅三尺。"],
|
||||
),
|
||||
StyleDimension(name="叙事人称", value="第三人称限知", evidence=["风停了"]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---- build_style_extract_request:tier 不变量、缓存断点、output_schema、run 非 stream ----
|
||||
|
||||
|
||||
def test_build_style_extract_request_uses_spec_tier_and_cached_prompt() -> None:
|
||||
req = build_style_extract_request(
|
||||
style_extract_spec, samples_text=SAMPLES, 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 == style_extract_spec.system_prompt
|
||||
assert req.system[0].cache is True # 不变量#9
|
||||
assert req.input == SAMPLES
|
||||
assert req.output_schema is StyleFingerprintResult
|
||||
assert req.scope.user_id == USER
|
||||
assert req.scope.project_id == PROJECT
|
||||
|
||||
|
||||
# ---- run_style_extraction:返回结构化指纹含证据,只读不写库 ----
|
||||
|
||||
|
||||
async def test_run_style_extraction_returns_fingerprint() -> None:
|
||||
gateway = FakeRunGateway(_FINGERPRINT)
|
||||
|
||||
result = await run_style_extraction(
|
||||
style_extract_spec,
|
||||
samples_text=SAMPLES,
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
assert isinstance(result, StyleFingerprintResult)
|
||||
assert len(result.dimensions) == 2
|
||||
assert result.dimensions[0].name == "句长节奏"
|
||||
assert result.dimensions[0].evidence == ["他一刀劈下,血溅三尺。"]
|
||||
assert gateway.call_count == 1
|
||||
|
||||
|
||||
async def test_run_style_extraction_forwards_constructed_request() -> None:
|
||||
gateway = FakeRunGateway(_FINGERPRINT)
|
||||
|
||||
await run_style_extraction(
|
||||
style_extract_spec,
|
||||
samples_text=SAMPLES,
|
||||
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 StyleFingerprintResult
|
||||
assert req.input == SAMPLES
|
||||
|
||||
|
||||
async def test_run_style_extraction_raises_when_gateway_fails() -> None:
|
||||
# 提取是独立生成(非流水线并行审);网关失败直接上抛,端点/T4.3 处理。
|
||||
gateway = FailingRunGateway(RuntimeError("provider down"))
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await run_style_extraction(
|
||||
style_extract_spec,
|
||||
samples_text=SAMPLES,
|
||||
gateway=gateway,
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
|
||||
|
||||
async def test_run_style_extraction_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_style_extraction(
|
||||
style_extract_spec,
|
||||
samples_text=SAMPLES,
|
||||
gateway=_NoParseGateway(), # type: ignore[arg-type]
|
||||
user_id=USER,
|
||||
project_id=PROJECT,
|
||||
)
|
||||
249
packages/core/tests/test_style_review.py
Normal file
249
packages/core/tests/test_style_review.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""T4.2 第四审(style/drift)单测——并入 review 图 + collect style 列 + SSE style 事件(C4 扩)。
|
||||
|
||||
全部注入 mock 网关 / 内存 fake repo,无真 LLM、无真 Postgres(图用 MemorySaver)。
|
||||
asyncio_mode=auto。验四审齐(continuity/foreshadow/pace/style)、collect 落 `style` 列、
|
||||
SSE `section{name:"style"}` + `style{score,segments}`、无指纹优雅降级。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fakes_orchestrator import FakeReviewRepo, SchemaRoutingRunGateway
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from ww_agents import (
|
||||
Conflict,
|
||||
ContinuityReview,
|
||||
ForeshadowReview,
|
||||
ForeshadowSuggestion,
|
||||
PaceIssue,
|
||||
PaceReview,
|
||||
StyleDriftReview,
|
||||
StyleDriftSegment,
|
||||
)
|
||||
from ww_core.orchestrator import (
|
||||
CONTINUITY,
|
||||
EVENT_CONFLICT,
|
||||
EVENT_DONE,
|
||||
EVENT_FORESHADOW,
|
||||
EVENT_PACE,
|
||||
EVENT_SECTION,
|
||||
EVENT_STYLE,
|
||||
FORESHADOW,
|
||||
PACE,
|
||||
REVIEW_INCOMPLETE,
|
||||
REVIEW_OK,
|
||||
REVIEW_SPECS,
|
||||
SECTION_DONE,
|
||||
STYLE,
|
||||
ChapterState,
|
||||
build_review_context,
|
||||
build_review_graph,
|
||||
collect_reviews,
|
||||
extract_style,
|
||||
normalize_review,
|
||||
)
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
STABLE = "## 世界观硬规则\n灵气可凝丹\n## 文风指纹\n句长节奏:短句明快"
|
||||
VOLATILE = "## 近况\n主角已破境"
|
||||
DRAFT = "他一拳轰碎山岳。"
|
||||
|
||||
_CONFLICT = Conflict(
|
||||
type="能力不符", where="第3段", refs=["人物卡:主角"], suggestion="改为震裂巨石"
|
||||
)
|
||||
_FORESHADOW = ForeshadowReview(
|
||||
planted=[ForeshadowSuggestion(code="FS-MARK", title="胸口胎记", where="第2段", note="皇族")]
|
||||
)
|
||||
_PACE = PaceReview(water=[PaceIssue(where="第4段", reason="注水")], hook=True, beat_map=[1, 3, 5])
|
||||
_STYLE = StyleDriftReview(
|
||||
score=72,
|
||||
segments=[
|
||||
StyleDriftSegment(idx=3, score=40, label="机翻腔"),
|
||||
StyleDriftSegment(idx=7, score=55),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _state(reviews: dict[str, Any] | None = None) -> ChapterState:
|
||||
state: ChapterState = {
|
||||
"project_id": PROJECT,
|
||||
"chapter_no": 7,
|
||||
"user_id": USER,
|
||||
"stable_core": STABLE,
|
||||
"volatile": VOLATILE,
|
||||
"draft": DRAFT,
|
||||
"review_context": build_review_context(draft=DRAFT, stable_core=STABLE, volatile=VOLATILE),
|
||||
}
|
||||
if reviews is not None:
|
||||
state["reviews"] = reviews
|
||||
return state
|
||||
|
||||
|
||||
def _four_reviews() -> dict[str, Any]:
|
||||
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()},
|
||||
STYLE: {"status": REVIEW_OK, "result": _STYLE.model_dump()},
|
||||
}
|
||||
|
||||
|
||||
# ---- REVIEW_SPECS 含第四审 ----
|
||||
|
||||
|
||||
def test_review_specs_include_four_audits() -> None:
|
||||
names = [s.name for s in REVIEW_SPECS]
|
||||
assert names == ["continuity", "foreshadow", "pace", "style"]
|
||||
|
||||
|
||||
# ---- collect:style 列映射 ----
|
||||
|
||||
|
||||
def test_extract_style_returns_whole_dict() -> None:
|
||||
style = extract_style(_four_reviews())
|
||||
|
||||
assert style is not None
|
||||
assert style["score"] == 72
|
||||
assert len(style["segments"]) == 2
|
||||
assert style["segments"][0]["label"] == "机翻腔"
|
||||
|
||||
|
||||
def test_extract_style_none_when_incomplete() -> None:
|
||||
reviews = {STYLE: {"status": REVIEW_INCOMPLETE, "result": None}}
|
||||
assert extract_style(reviews) is None
|
||||
|
||||
|
||||
def test_extract_style_none_when_absent() -> None:
|
||||
assert extract_style({}) is None
|
||||
|
||||
|
||||
async def test_collect_records_style_to_style_column() -> None:
|
||||
repo = FakeReviewRepo()
|
||||
|
||||
await collect_reviews(_state(_four_reviews()), review_repo=repo)
|
||||
|
||||
assert len(repo.records) == 1 # 一次 record 落齐四审
|
||||
rec = repo.records[0]
|
||||
assert rec["conflicts"][0]["type"] == "能力不符"
|
||||
assert len(rec["foreshadow_sug"]) == 1
|
||||
assert rec["pace"]["hook"] is True
|
||||
assert rec["style"]["score"] == 72 # style→style 列
|
||||
assert rec["style"]["segments"][0]["label"] == "机翻腔"
|
||||
|
||||
|
||||
# ---- 四审并行图跑通(mock 网关按 schema 路由产四 parsed)----
|
||||
|
||||
|
||||
async def test_four_review_graph_runs_all_audits_end_to_end() -> None:
|
||||
gateway = SchemaRoutingRunGateway(
|
||||
{
|
||||
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
|
||||
ForeshadowReview: _FORESHADOW,
|
||||
PaceReview: _PACE,
|
||||
StyleDriftReview: _STYLE,
|
||||
}
|
||||
)
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "rev-style-1"}}
|
||||
|
||||
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 final["reviews"][STYLE]["status"] == REVIEW_OK
|
||||
assert len(gateway.calls) == 4 # 四审各调一次网关
|
||||
rec = repo.records[0]
|
||||
assert rec["conflicts"] and rec["foreshadow_sug"] and rec["pace"]
|
||||
assert rec["style"]["score"] == 72
|
||||
|
||||
|
||||
async def test_four_review_graph_isolates_failing_style_audit() -> None:
|
||||
# style schema 缺失 → SchemaRoutingRunGateway 抛 KeyError → run_review 隔离为 incomplete
|
||||
gateway = SchemaRoutingRunGateway(
|
||||
{
|
||||
ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
|
||||
ForeshadowReview: _FORESHADOW,
|
||||
PaceReview: _PACE,
|
||||
}
|
||||
)
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "rev-style-2"}}
|
||||
|
||||
final = await graph.ainvoke(_state(), config=config)
|
||||
|
||||
assert final["reviews"][STYLE]["status"] == REVIEW_INCOMPLETE
|
||||
assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
|
||||
rec = repo.records[0]
|
||||
assert rec["conflicts"] # 其余三审照常落库
|
||||
assert rec["style"] is None # style 失败列留空,不阻塞其余
|
||||
|
||||
|
||||
# ---- SSE:normalize_review surface 四审(含 style 事件)----
|
||||
|
||||
|
||||
async def test_normalize_review_surfaces_style_event() -> None:
|
||||
events = [e async for e in normalize_review(_four_reviews())]
|
||||
kinds = [e.event for e in events]
|
||||
|
||||
# 字典序遍历:continuity < foreshadow < pace < style
|
||||
assert kinds == [
|
||||
EVENT_SECTION,
|
||||
EVENT_CONFLICT,
|
||||
EVENT_SECTION,
|
||||
EVENT_FORESHADOW,
|
||||
EVENT_SECTION,
|
||||
EVENT_PACE,
|
||||
EVENT_SECTION,
|
||||
EVENT_STYLE,
|
||||
EVENT_DONE,
|
||||
]
|
||||
style_evt = next(e for e in events if e.event == EVENT_STYLE)
|
||||
assert style_evt.data["score"] == 72
|
||||
segments = style_evt.data["segments"]
|
||||
assert segments == [
|
||||
{"idx": 3, "score": 40, "label": "机翻腔"},
|
||||
{"idx": 7, "score": 55, "label": None},
|
||||
]
|
||||
style_section = next(
|
||||
e for e in events if e.event == EVENT_SECTION and e.data.get("name") == STYLE
|
||||
)
|
||||
assert style_section.data["status"] == SECTION_DONE
|
||||
assert events[-1].data == {"length": 4} # 四审项
|
||||
|
||||
|
||||
# ---- 无指纹优雅降级:style 审返回 score=100/空段(默认 schema)落库且 SSE 不报错 ----
|
||||
|
||||
|
||||
async def test_style_graceful_degrade_when_no_fingerprint() -> None:
|
||||
# 材料无指纹 → 第四审产默认 StyleDriftReview(score=100, segments=[])(system_prompt 指示)
|
||||
degraded = StyleDriftReview() # score=100, segments=[]
|
||||
gateway = SchemaRoutingRunGateway(
|
||||
{
|
||||
ContinuityReview: ContinuityReview(conflicts=[]),
|
||||
ForeshadowReview: ForeshadowReview(),
|
||||
PaceReview: PaceReview(),
|
||||
StyleDriftReview: degraded,
|
||||
}
|
||||
)
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "rev-style-degrade"}}
|
||||
|
||||
final = await graph.ainvoke(_state(), config=config)
|
||||
|
||||
assert final["reviews"][STYLE]["status"] == REVIEW_OK
|
||||
rec = repo.records[0]
|
||||
assert rec["style"] == {"score": 100, "segments": []} # 降级也落库(非 None)
|
||||
|
||||
events = [e async for e in normalize_review(final["reviews"])]
|
||||
style_evt = next(e for e in events if e.event == EVENT_STYLE)
|
||||
assert style_evt.data["score"] == 100
|
||||
assert style_evt.data["segments"] == []
|
||||
@@ -8,6 +8,11 @@ from ww_core.domain.chapter_repo import (
|
||||
ChapterView,
|
||||
SqlChapterRepo,
|
||||
)
|
||||
from ww_core.domain.character_repo import (
|
||||
CharacterWriteRepo,
|
||||
CharacterWriteView,
|
||||
SqlCharacterWriteRepo,
|
||||
)
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
||||
from ww_core.domain.foreshadow_repo import (
|
||||
ForeshadowLedgerRepo,
|
||||
@@ -25,6 +30,11 @@ from ww_core.domain.foreshadow_state import (
|
||||
is_overdue,
|
||||
transition,
|
||||
)
|
||||
from ww_core.domain.job_repo import (
|
||||
JobRepo,
|
||||
JobView,
|
||||
SqlJobRepo,
|
||||
)
|
||||
from ww_core.domain.outline_write_repo import (
|
||||
OutlineWriteRepo,
|
||||
OutlineWriteView,
|
||||
@@ -38,12 +48,29 @@ from ww_core.domain.project_repo import (
|
||||
)
|
||||
from ww_core.domain.repositories import DigestView, MemoryRepos
|
||||
from ww_core.domain.review_repo import ReviewRepo, ReviewView, SqlReviewRepo
|
||||
from ww_core.domain.rule_repo import RuleWriteRepo, RuleWriteView, SqlRuleWriteRepo
|
||||
from ww_core.domain.style_repo import (
|
||||
SqlStyleFingerprintWriteRepo,
|
||||
StyleFingerprintView,
|
||||
StyleFingerprintWriteRepo,
|
||||
)
|
||||
from ww_core.domain.world_entity_repo import (
|
||||
SqlWorldEntityWriteRepo,
|
||||
WorldEntityWriteRepo,
|
||||
WorldEntityWriteView,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ChapterDraftView",
|
||||
"ChapterRepo",
|
||||
"ChapterView",
|
||||
"SqlChapterRepo",
|
||||
"CharacterWriteRepo",
|
||||
"CharacterWriteView",
|
||||
"SqlCharacterWriteRepo",
|
||||
"WorldEntityWriteRepo",
|
||||
"WorldEntityWriteView",
|
||||
"SqlWorldEntityWriteRepo",
|
||||
"DigestAppendRepo",
|
||||
"SqlDigestAppendRepo",
|
||||
"DigestView",
|
||||
@@ -52,6 +79,9 @@ __all__ = [
|
||||
"SqlForeshadowLedgerRepo",
|
||||
"ForeshadowStatus",
|
||||
"InvalidTransition",
|
||||
"JobRepo",
|
||||
"JobView",
|
||||
"SqlJobRepo",
|
||||
"OPEN",
|
||||
"PARTIAL",
|
||||
"CLOSED",
|
||||
@@ -70,4 +100,10 @@ __all__ = [
|
||||
"ReviewRepo",
|
||||
"ReviewView",
|
||||
"SqlReviewRepo",
|
||||
"RuleWriteView",
|
||||
"RuleWriteRepo",
|
||||
"SqlRuleWriteRepo",
|
||||
"StyleFingerprintWriteRepo",
|
||||
"StyleFingerprintView",
|
||||
"SqlStyleFingerprintWriteRepo",
|
||||
]
|
||||
|
||||
121
packages/core/ww_core/domain/character_repo.py
Normal file
121
packages/core/ww_core/domain/character_repo.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""角色**写侧** Repository(C3 扩 / ARCH §5.4 character-gen writes=characters / §6.5 入库)。
|
||||
|
||||
读侧(`list_for_project`,供 assemble 注入)已在
|
||||
`ww_core.memory.sql_repositories.SqlCharacterRepo` + `domain.repositories.CharacterRepo`
|
||||
提供(C5 稳定,不动)。本模块加**写**能力,命名加 `Write` 前缀避歧义(同 `OutlineWriteRepo` 先例)。
|
||||
|
||||
**schema → DB 列形变(T5.1 gotcha,关键)**:`ww_agents.CharacterCard` 贴生成产物
|
||||
(`traits`/`speech_tics` 是 `list[str]`、`arc` 是 `str`),但 `characters` 表的对应列是
|
||||
JSONB **dict**(`traits`/`arc`/`speech_tics`),`tags`/`relations` 才是 JSONB **list**。
|
||||
入库时本 repo 做转换层:
|
||||
- `traits: list[str]` → `{"items": [...]}`(JSONB dict)
|
||||
- `speech_tics: list[str]` → `{"items": [...]}`(JSONB dict)
|
||||
- `arc: str` → `{"text": "..."}`(JSONB dict)
|
||||
- `tags: list` / `relations: list[dict]` → 直落 JSONB list
|
||||
- `name` / `role` / `backstory` → Text 列直落
|
||||
|
||||
**提交边界**:`create` 只 `flush()` 不 `commit()`——提交交端点事务(入库端点末尾一次
|
||||
`commit()`,与网关 ledger 一并落库;与项目其它写侧 repo 一致,见 memory/gotchas)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import Character
|
||||
|
||||
|
||||
class CharacterWriteView(BaseModel):
|
||||
"""角色写入后的只读快照(snake_case,frozen)。"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
role: str | None = None
|
||||
|
||||
|
||||
def _traits_to_jsonb(items: list[str]) -> dict[str, Any]:
|
||||
"""`list[str]` → JSONB dict(DB 列形)。空列表也包成 `{"items": []}` 形稳定。"""
|
||||
return {"items": list(items)}
|
||||
|
||||
|
||||
def _arc_to_jsonb(arc: str) -> dict[str, Any]:
|
||||
"""弧光一句话 `str` → JSONB dict(DB 列形)。"""
|
||||
return {"text": arc}
|
||||
|
||||
|
||||
class CharacterWriteRepo(Protocol):
|
||||
"""角色写侧接口(按 project_id 隔离;只 flush 不 commit)。
|
||||
|
||||
入参贴 `ww_agents.CharacterCard`(生成产物形);实现负责 schema → DB 列形变。
|
||||
"""
|
||||
|
||||
async def create(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
name: str,
|
||||
role: str,
|
||||
traits: list[str],
|
||||
backstory: str,
|
||||
arc: str,
|
||||
speech_tics: list[str],
|
||||
tags: list[Any],
|
||||
relations: list[dict[str, Any]],
|
||||
) -> CharacterWriteView: ...
|
||||
|
||||
|
||||
class SqlCharacterWriteRepo:
|
||||
"""SQLAlchemy 实现:插一行 `characters`(schema→DB 形变;只 flush 不 commit)。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
name: str,
|
||||
role: str,
|
||||
traits: list[str],
|
||||
backstory: str,
|
||||
arc: str,
|
||||
speech_tics: list[str],
|
||||
tags: list[Any],
|
||||
relations: list[dict[str, Any]],
|
||||
) -> CharacterWriteView:
|
||||
row = Character(
|
||||
project_id=project_id,
|
||||
name=name,
|
||||
role=role,
|
||||
traits=_traits_to_jsonb(traits),
|
||||
backstory=backstory,
|
||||
arc=_arc_to_jsonb(arc),
|
||||
speech_tics=_traits_to_jsonb(speech_tics),
|
||||
tags=list(tags),
|
||||
relations=[dict(r) for r in relations],
|
||||
)
|
||||
self._s.add(row)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return CharacterWriteView(id=row.id, name=row.name, role=row.role)
|
||||
|
||||
|
||||
# ---- request-shaping helper(schema 字段名导出供测试/端点共用)----
|
||||
|
||||
|
||||
class CharacterWriteFields(BaseModel):
|
||||
"""从 `CharacterCard` 拆出的入库字段(端点把 schema 卡转成此 kwargs 形)。"""
|
||||
|
||||
name: str
|
||||
role: str
|
||||
traits: list[str] = Field(default_factory=list)
|
||||
backstory: str
|
||||
arc: str
|
||||
speech_tics: list[str] = Field(default_factory=list)
|
||||
tags: list[Any] = Field(default_factory=list)
|
||||
relations: list[dict[str, Any]] = Field(default_factory=list)
|
||||
169
packages/core/ww_core/domain/job_repo.py
Normal file
169
packages/core/ww_core/domain/job_repo.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""长任务 `jobs` 表**写侧** Repository(创建/进度/完成/失败/僵尸回收;ARCH §7.4)。
|
||||
|
||||
`jobs` 表 + `GET /jobs/{id}` 读端点已在 T0.3 建齐;本模块补**写/进度/回收**层,
|
||||
供 T4.3「学文风走 jobs」(`POST /style` 写一行 → BackgroundTask 跑提取 → 置 done/failed)
|
||||
复用。命名沿用读侧无歧义(无读侧同名 repo,故不加前缀)。
|
||||
|
||||
**提交边界**(同 M2/M3 写侧 repo 与 memory/gotchas「写库副作用归调用方」纪律):
|
||||
所有状态写方法**只 `flush()`(+`refresh`)不 `commit()`**——提交交调用方:
|
||||
`run_job`(job_runner)/端点事务负责 commit。
|
||||
**唯一例外** `reap_zombies`:它在 lifespan 启动期**独立调用**(无外层事务/调用方),
|
||||
故自己 `commit()`。
|
||||
|
||||
`JobView`(frozen)镜像 `GET /jobs/{id}` 出参字段:id/kind/status/progress/result/error。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Protocol, cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import CursorResult, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import Job
|
||||
|
||||
# job 状态常量(与 DB server_default 'queued' 对齐;ARCH §7.4)。
|
||||
STATUS_QUEUED = "queued"
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_DONE = "done"
|
||||
STATUS_FAILED = "failed"
|
||||
PROGRESS_COMPLETE = 100
|
||||
|
||||
|
||||
class JobView(BaseModel):
|
||||
"""长任务只读快照(snake_case,frozen)——镜像 GET /jobs/{id} 出参。"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
id: uuid.UUID
|
||||
kind: str
|
||||
status: str
|
||||
progress: int = 0
|
||||
result: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class JobRepo(Protocol):
|
||||
"""长任务写侧接口(状态写方法只 flush 不 commit;`reap_zombies` 自 commit)。"""
|
||||
|
||||
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
|
||||
"""创建一行 job(status=queued, progress=0)。"""
|
||||
...
|
||||
|
||||
async def set_running(self, job_id: uuid.UUID) -> JobView:
|
||||
"""置 status=running(任务开始)。"""
|
||||
...
|
||||
|
||||
async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView:
|
||||
"""更新进度百分比(0..100,越界夹取)。"""
|
||||
...
|
||||
|
||||
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
|
||||
"""置 status=done, progress=100, result=<dict>。"""
|
||||
...
|
||||
|
||||
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
|
||||
"""置 status=failed, error=<str>。"""
|
||||
...
|
||||
|
||||
async def get(self, job_id: uuid.UUID) -> JobView | None:
|
||||
"""按 id 取单条,无则 None。"""
|
||||
...
|
||||
|
||||
async def reap_zombies(self) -> int:
|
||||
"""把所有 status=running 的僵尸行标 failed,返回被改条数(启动期自 commit)。"""
|
||||
...
|
||||
|
||||
|
||||
def _to_view(row: Job) -> JobView:
|
||||
return JobView(
|
||||
id=row.id,
|
||||
kind=row.kind,
|
||||
status=row.status,
|
||||
progress=row.progress,
|
||||
result=row.result,
|
||||
error=row.error,
|
||||
)
|
||||
|
||||
|
||||
def _clamp_pct(pct: int) -> int:
|
||||
return max(0, min(PROGRESS_COMPLETE, pct))
|
||||
|
||||
|
||||
class SqlJobRepo:
|
||||
"""SQLAlchemy 实现:创建 + 状态流转 + 进度 + 僵尸回收。
|
||||
|
||||
状态写方法只 flush(+refresh);`reap_zombies` 启动期独立调用故自 commit。
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def _find(self, job_id: uuid.UUID) -> Job | None:
|
||||
return (await self._s.execute(select(Job).where(Job.id == job_id))).scalar_one_or_none()
|
||||
|
||||
async def _require(self, job_id: uuid.UUID) -> Job:
|
||||
row = await self._find(job_id)
|
||||
if row is None:
|
||||
raise LookupError(f"job not found: {job_id}")
|
||||
return row
|
||||
|
||||
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
|
||||
row = Job(
|
||||
project_id=project_id,
|
||||
kind=kind,
|
||||
status=STATUS_QUEUED,
|
||||
progress=0,
|
||||
)
|
||||
self._s.add(row)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return _to_view(row)
|
||||
|
||||
async def set_running(self, job_id: uuid.UUID) -> JobView:
|
||||
row = await self._require(job_id)
|
||||
row.status = STATUS_RUNNING
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return _to_view(row)
|
||||
|
||||
async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView:
|
||||
row = await self._require(job_id)
|
||||
row.progress = _clamp_pct(pct)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return _to_view(row)
|
||||
|
||||
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
|
||||
row = await self._require(job_id)
|
||||
row.status = STATUS_DONE
|
||||
row.progress = PROGRESS_COMPLETE
|
||||
row.result = dict(result)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return _to_view(row)
|
||||
|
||||
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
|
||||
row = await self._require(job_id)
|
||||
row.status = STATUS_FAILED
|
||||
row.error = error
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return _to_view(row)
|
||||
|
||||
async def get(self, job_id: uuid.UUID) -> JobView | None:
|
||||
row = await self._find(job_id)
|
||||
return _to_view(row) if row is not None else None
|
||||
|
||||
async def reap_zombies(self) -> int:
|
||||
"""启动期僵尸回收:进程重启会丢 BackgroundTask,残留 running 行标 failed
|
||||
(让用户看到失败可重试,而非进度条永转;§7.4 缓解)。独立调用故自 commit。"""
|
||||
result = await self._s.execute(
|
||||
update(Job)
|
||||
.where(Job.status == STATUS_RUNNING)
|
||||
.values(status=STATUS_FAILED, error="job interrupted (process restart)")
|
||||
)
|
||||
await self._s.commit()
|
||||
# bulk UPDATE → CursorResult.rowcount = 受影响行数(`Result` 基类无此属性)。
|
||||
return cast("CursorResult[Any]", result).rowcount
|
||||
@@ -86,12 +86,29 @@ class RuleView(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class ProjectSpecView(BaseModel):
|
||||
"""项目书级蓝本只读快照(premise/logline/theme/title)——稳定、定型,入缓存前缀。
|
||||
|
||||
这是「作品蓝本」:哪怕世界观/角色/大纲全空,它也保证写章 prompt 非空、有方向
|
||||
(修复空 prompt 导致的 400 "message must not be empty")。
|
||||
"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
title: str
|
||||
logline: str | None = None
|
||||
premise: str | None = None
|
||||
theme: str | None = None
|
||||
|
||||
|
||||
# ---- Repository 协议(async;统一按 project_id 过滤)----
|
||||
|
||||
|
||||
class OutlineRepo(Protocol):
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None: ...
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]: ...
|
||||
|
||||
|
||||
class CharacterRepo(Protocol):
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]: ...
|
||||
@@ -119,9 +136,13 @@ class RulesRepo(Protocol):
|
||||
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]: ...
|
||||
|
||||
|
||||
class ProjectSpecRepo(Protocol):
|
||||
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryRepos:
|
||||
"""记忆服务所需的 7 个 repo 的依赖捆绑(注入点)。"""
|
||||
"""记忆服务所需的 8 个 repo 的依赖捆绑(注入点)。"""
|
||||
|
||||
outline: OutlineRepo
|
||||
character: CharacterRepo
|
||||
@@ -130,3 +151,4 @@ class MemoryRepos:
|
||||
foreshadow: ForeshadowRepo
|
||||
style: StyleRepo
|
||||
rules: RulesRepo
|
||||
project: ProjectSpecRepo
|
||||
|
||||
49
packages/core/ww_core/domain/rule_repo.py
Normal file
49
packages/core/ww_core/domain/rule_repo.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""规则**写侧** Repository(C3 扩 / PRODUCT_SPEC §7 `POST /projects/:id/rules`)。
|
||||
|
||||
读侧(`all_for_project`,供 assemble 注入 + `merge_rules` 四级合并)已在
|
||||
`ww_core.memory.sql_repositories.SqlRulesRepo` 提供(C5,不动)。本模块加**写**能力,
|
||||
命名加 `Write` 前缀避免歧义(同 `OutlineWriteRepo`/`DigestAppendRepo` 先例)。
|
||||
|
||||
`level` ∈ global/genre/style/project(合法性由端点/schema 校验,repo 只写)。
|
||||
**提交边界**:`create` 只 `flush()` 不 `commit()`——提交交端点事务(与项目其它写侧
|
||||
repo 一致,见 memory/gotchas)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import Rule
|
||||
|
||||
|
||||
class RuleWriteView(BaseModel):
|
||||
"""规则写入后的只读快照(snake_case,frozen)。"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
project_id: uuid.UUID | None
|
||||
level: str
|
||||
content: str
|
||||
|
||||
|
||||
class RuleWriteRepo(Protocol):
|
||||
"""规则写侧接口(绑 project_id;只 flush 不 commit)。"""
|
||||
|
||||
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView: ...
|
||||
|
||||
|
||||
class SqlRuleWriteRepo:
|
||||
"""SQLAlchemy 实现:插一行 `rules`(只 flush 不 commit)。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
|
||||
row = Rule(project_id=project_id, level=level, content=content)
|
||||
self._s.add(row)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return RuleWriteView(project_id=row.project_id, level=row.level, content=row.content)
|
||||
108
packages/core/ww_core/domain/style_repo.py
Normal file
108
packages/core/ww_core/domain/style_repo.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""文风指纹**写侧** Repository(版本化 append + 最新指纹读取;ARCH §5.4 / §6.9)。
|
||||
|
||||
读侧(`latest`,供 assemble 注入 stable_core 文风段)已在
|
||||
`ww_core.memory.sql_repositories.SqlStyleRepo` / `domain.repositories.StyleRepo` 提供,
|
||||
但其 `StyleView` **只含 `dimensions`**(C5 assemble 只需维度文本,不需证据/版本)。
|
||||
本模块加**写**能力 + 一个含证据/版本的完整读视图(供 T4.3 `GET /projects/:id/style`
|
||||
展示完整指纹,对齐 UX §6.9),命名加 `Write` 前缀避免与 C5 读侧 `SqlStyleRepo` 同名歧义
|
||||
(同 `DigestAppendRepo`/`SqlOutlineWriteRepo` 先例,见 memory/decisions),且**不动 C5 读侧**。
|
||||
|
||||
**提交边界**(同 M2/M3 写侧 repo 与 memory/gotchas「写库副作用归调用方」纪律):
|
||||
`append` **只 `flush()`(+`refresh`)不 `commit()`**——提交交 `run_job`(学文风后台任务)/端点。
|
||||
|
||||
版本化:`style_fingerprint` 每次提取 INSERT 新行(不覆盖历史),`version` = 当前
|
||||
project 的 `max(version) + 1`(首次 = 1)。提取产 `StyleFingerprintResult` 由调用方拆成
|
||||
`dimensions_json = {dim.name: dim.value}` + `evidence_json = {dim.name: dim.evidence}`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import StyleFingerprint
|
||||
|
||||
|
||||
class StyleFingerprintView(BaseModel):
|
||||
"""文风指纹完整只读快照(snake_case,frozen)——含维度 + 证据 + 版本。
|
||||
|
||||
区别于 C5 读侧最小 `StyleView`(只 `dimensions`):本视图供 `GET /style`
|
||||
展示完整指纹(每维度名 → 值 + 原文证据列表)。
|
||||
"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
dimensions: dict[str, Any] = Field(default_factory=dict)
|
||||
evidence: dict[str, Any] = Field(default_factory=dict)
|
||||
version: int
|
||||
|
||||
|
||||
class StyleFingerprintWriteRepo(Protocol):
|
||||
"""文风指纹写侧接口(按 project_id 隔离;`append` 只 flush 不 commit)。"""
|
||||
|
||||
async def append(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
dimensions_json: dict[str, Any],
|
||||
evidence_json: dict[str, Any],
|
||||
) -> int:
|
||||
"""追加一行文风指纹(不覆盖历史),返回新行 `version`(= 当前 max + 1,首次 = 1)。"""
|
||||
...
|
||||
|
||||
async def latest(self, project_id: uuid.UUID) -> StyleFingerprintView | None:
|
||||
"""取最新版本指纹(version 最大)的完整视图(含证据),无则 None。"""
|
||||
...
|
||||
|
||||
|
||||
class SqlStyleFingerprintWriteRepo:
|
||||
"""SQLAlchemy 实现:版本化 INSERT(append-only)+ 最新指纹读取(只 flush 不 commit)。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def append(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
dimensions_json: dict[str, Any],
|
||||
evidence_json: dict[str, Any],
|
||||
) -> int:
|
||||
current_max = (
|
||||
await self._s.execute(
|
||||
select(func.max(StyleFingerprint.version)).where(
|
||||
StyleFingerprint.project_id == project_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
next_version = (current_max or 0) + 1
|
||||
row = StyleFingerprint(
|
||||
project_id=project_id,
|
||||
dimensions_json=dict(dimensions_json),
|
||||
evidence_json=dict(evidence_json),
|
||||
version=next_version,
|
||||
)
|
||||
self._s.add(row)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return row.version
|
||||
|
||||
async def latest(self, project_id: uuid.UUID) -> StyleFingerprintView | None:
|
||||
row = (
|
||||
await self._s.execute(
|
||||
select(StyleFingerprint)
|
||||
.where(StyleFingerprint.project_id == project_id)
|
||||
.order_by(StyleFingerprint.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return StyleFingerprintView(
|
||||
dimensions=dict(row.dimensions_json),
|
||||
evidence=dict(row.evidence_json),
|
||||
version=row.version,
|
||||
)
|
||||
75
packages/core/ww_core/domain/world_entity_repo.py
Normal file
75
packages/core/ww_core/domain/world_entity_repo.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""世界观实体**写侧** Repository(C3 扩 / ARCH §5.4 worldbuilder writes=world_entities)。
|
||||
|
||||
读侧(`list_for_project`,供 assemble 注入)已在
|
||||
`ww_core.memory.sql_repositories.SqlWorldEntityRepo` + `domain.repositories.WorldEntityRepo`/
|
||||
`WorldEntityView` 提供(C5 稳定,不动)。本模块加**写**能力,命名加 `Write` 前缀避歧义。
|
||||
|
||||
**schema → DB 列形变(T5.1 gotcha)**:`ww_agents.WorldEntityCard.rules` 是 `list[str]`,
|
||||
而 `world_entities.rules` 列是 JSONB **dict**——入库时包成 `{"rules": [...]}`。
|
||||
`type`/`name` 是 Text 列直落。
|
||||
|
||||
**提交边界**:`create` 只 `flush()` 不 `commit()`——提交交端点事务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import WorldEntity
|
||||
|
||||
|
||||
class WorldEntityWriteView(BaseModel):
|
||||
"""世界观实体写入后的只读快照(snake_case,frozen)。"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
id: uuid.UUID
|
||||
type: str
|
||||
name: str
|
||||
|
||||
|
||||
def _rules_to_jsonb(rules: list[str]) -> dict[str, Any]:
|
||||
"""`list[str]` → JSONB dict(DB 列形)。"""
|
||||
return {"rules": list(rules)}
|
||||
|
||||
|
||||
class WorldEntityWriteRepo(Protocol):
|
||||
"""世界观实体写侧接口(按 project_id 隔离;只 flush 不 commit)。"""
|
||||
|
||||
async def create(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
type: str,
|
||||
name: str,
|
||||
rules: list[str],
|
||||
) -> WorldEntityWriteView: ...
|
||||
|
||||
|
||||
class SqlWorldEntityWriteRepo:
|
||||
"""SQLAlchemy 实现:插一行 `world_entities`(rules list→dict;只 flush 不 commit)。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def create(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
type: str,
|
||||
name: str,
|
||||
rules: list[str],
|
||||
) -> WorldEntityWriteView:
|
||||
row = WorldEntity(
|
||||
project_id=project_id,
|
||||
type=type,
|
||||
name=name,
|
||||
rules=_rules_to_jsonb(rules),
|
||||
)
|
||||
self._s.add(row)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return WorldEntityWriteView(id=row.id, type=row.type, name=row.name)
|
||||
@@ -17,6 +17,7 @@ from ww_core.domain.repositories import (
|
||||
ForeshadowView,
|
||||
MemoryRepos,
|
||||
OutlineView,
|
||||
ProjectSpecView,
|
||||
RuleView,
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
@@ -43,7 +44,25 @@ def _section(title: str, body: str) -> str | None:
|
||||
return f"## {title}\n{body}" if body else None
|
||||
|
||||
|
||||
def _build_spec_section(spec: ProjectSpecView | None) -> str | None:
|
||||
"""作品书级蓝本(标题/一句话梗概/前提/主题)——稳定、定型 → 缓存前缀。
|
||||
|
||||
保证哪怕世界观/角色/大纲全空,stable_core 也非空、有方向(修空 prompt 400)。
|
||||
"""
|
||||
if spec is None:
|
||||
return None
|
||||
lines = [f"【作品】{spec.title}"]
|
||||
if spec.logline:
|
||||
lines.append(f"【一句话梗概】{spec.logline}")
|
||||
if spec.premise:
|
||||
lines.append(f"【前提】{spec.premise}")
|
||||
if spec.theme:
|
||||
lines.append(f"【主题】{spec.theme}")
|
||||
return _section("作品蓝本", "\n".join(lines))
|
||||
|
||||
|
||||
def _build_stable(
|
||||
spec: ProjectSpecView | None,
|
||||
world_entities: list[WorldEntityView],
|
||||
main_characters: list[CharacterView],
|
||||
style: StyleView | None,
|
||||
@@ -64,6 +83,7 @@ def _build_stable(
|
||||
rule_lines = [f"[{r.level}] {r.content}" for r in merge_rules(rules)]
|
||||
|
||||
sections = [
|
||||
_build_spec_section(spec),
|
||||
_section("世界观硬规则", "\n".join(world_lines)),
|
||||
_section("定型主角", "\n".join(char_lines)),
|
||||
_section("文风指纹", _ser(style.dimensions) if style else ""),
|
||||
@@ -73,6 +93,7 @@ def _build_stable(
|
||||
|
||||
|
||||
def _build_volatile(
|
||||
chapter_no: int,
|
||||
cards: str,
|
||||
foreshadows: list[ForeshadowView],
|
||||
digests: list[DigestView],
|
||||
@@ -86,6 +107,8 @@ def _build_volatile(
|
||||
f"第{d.chapter_no}章:{_ser(d.facts)}" for d in sorted(digests, key=lambda d: d.chapter_no)
|
||||
]
|
||||
sections = [
|
||||
# 写章指令始终在场——保证 volatile(断点后的 input)永不为空(修空 prompt 400)。
|
||||
_section("写作指令", f"请创作第 {chapter_no} 章的正文。"),
|
||||
_section("本章注入卡片", cards),
|
||||
_section("相关伏笔窗口", "\n".join(fore_lines)),
|
||||
_section("近况摘要", "\n".join(digest_lines)),
|
||||
@@ -119,11 +142,12 @@ async def assemble(
|
||||
foreshadows = await repos.foreshadow.list_for_codes(project_id, codes)
|
||||
style = await repos.style.latest(project_id)
|
||||
rules = await repos.rules.all_for_project(project_id)
|
||||
spec = await repos.project.spec(project_id)
|
||||
|
||||
main_characters = [c for c in characters if (c.role or "") in MAIN_ROLES]
|
||||
|
||||
stable_core = _build_stable(world_entities, main_characters, style, rules)
|
||||
stable_core = _build_stable(spec, world_entities, main_characters, style, rules)
|
||||
cards = render_cards(selection, characters, world_entities)
|
||||
volatile = _build_volatile(cards, foreshadows, recent_digests, outline.beats)
|
||||
volatile = _build_volatile(chapter_no, cards, foreshadows, recent_digests, outline.beats)
|
||||
|
||||
return AssembledContext(stable_core=stable_core, volatile=volatile, selection=selection)
|
||||
|
||||
@@ -15,6 +15,7 @@ from ww_db.models import (
|
||||
Character,
|
||||
Foreshadow,
|
||||
Outline,
|
||||
Project,
|
||||
Rule,
|
||||
StyleFingerprint,
|
||||
WorldEntity,
|
||||
@@ -26,6 +27,7 @@ from ww_core.domain.repositories import (
|
||||
ForeshadowView,
|
||||
MemoryRepos,
|
||||
OutlineView,
|
||||
ProjectSpecView,
|
||||
RuleView,
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
@@ -53,6 +55,22 @@ class SqlOutlineRepo:
|
||||
foreshadow_windows=list(row.foreshadow_windows or []),
|
||||
)
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
rows = (
|
||||
await self._s.execute(
|
||||
select(Outline).where(Outline.project_id == project_id).order_by(Outline.chapter_no)
|
||||
)
|
||||
).scalars()
|
||||
return [
|
||||
OutlineView(
|
||||
volume=r.volume,
|
||||
chapter_no=r.chapter_no,
|
||||
beats=r.beats or {},
|
||||
foreshadow_windows=list(r.foreshadow_windows or []),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
class SqlCharacterRepo:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
@@ -177,8 +195,31 @@ class SqlRulesRepo:
|
||||
return [RuleView(level=r.level, content=r.content) for r in rows]
|
||||
|
||||
|
||||
class SqlProjectSpecRepo:
|
||||
"""读项目书级蓝本(premise/logline/theme/title)供 assemble 注入缓存前缀。
|
||||
|
||||
按 project_id 读(不带 owner_id:assemble 已在 project 维度过滤,owner 隔离归路由层)。
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
|
||||
row = (
|
||||
await self._s.execute(select(Project).where(Project.id == project_id))
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return ProjectSpecView(
|
||||
title=row.title,
|
||||
logline=row.logline,
|
||||
premise=row.premise,
|
||||
theme=row.theme,
|
||||
)
|
||||
|
||||
|
||||
def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
|
||||
"""用一个 AsyncSession 装配全部 7 个 SQLAlchemy repo(T1.4 注入点)。"""
|
||||
"""用一个 AsyncSession 装配全部 8 个 SQLAlchemy repo(T1.4 注入点)。"""
|
||||
return MemoryRepos(
|
||||
outline=SqlOutlineRepo(session),
|
||||
character=SqlCharacterRepo(session),
|
||||
@@ -187,4 +228,5 @@ def sql_memory_repos(session: AsyncSession) -> MemoryRepos:
|
||||
foreshadow=SqlForeshadowRepo(session),
|
||||
style=SqlStyleRepo(session),
|
||||
rules=SqlRulesRepo(session),
|
||||
project=SqlProjectSpecRepo(session),
|
||||
)
|
||||
|
||||
@@ -12,11 +12,21 @@ from .collect import (
|
||||
CONTINUITY,
|
||||
FORESHADOW,
|
||||
PACE,
|
||||
STYLE,
|
||||
ReviewRecorder,
|
||||
collect_reviews,
|
||||
extract_conflicts,
|
||||
extract_foreshadow_sug,
|
||||
extract_pace,
|
||||
extract_style,
|
||||
)
|
||||
from .generation_node import (
|
||||
build_character_gen_context,
|
||||
build_precheck_context,
|
||||
build_worldbuilder_context,
|
||||
precheck_generated_cards,
|
||||
run_character_gen,
|
||||
run_worldbuilder,
|
||||
)
|
||||
from .graph import (
|
||||
COLLECT_NODE,
|
||||
@@ -44,6 +54,7 @@ from .sse import (
|
||||
EVENT_FORESHADOW,
|
||||
EVENT_PACE,
|
||||
EVENT_SECTION,
|
||||
EVENT_STYLE,
|
||||
EVENT_TOKEN,
|
||||
SECTION_DONE,
|
||||
SECTION_INCOMPLETE,
|
||||
@@ -57,9 +68,11 @@ from .sse import (
|
||||
normalize_review,
|
||||
pace_event,
|
||||
section_event,
|
||||
style_event,
|
||||
token_event,
|
||||
)
|
||||
from .state import ChapterState, merge_reviews
|
||||
from .style_extract_node import build_style_extract_request, run_style_extraction
|
||||
from .write_node import GatewayStream, build_write_request, stream_chapter_draft, write_node
|
||||
|
||||
__all__ = [
|
||||
@@ -71,6 +84,7 @@ __all__ = [
|
||||
"EVENT_FORESHADOW",
|
||||
"EVENT_PACE",
|
||||
"EVENT_SECTION",
|
||||
"EVENT_STYLE",
|
||||
"EVENT_TOKEN",
|
||||
"FORESHADOW",
|
||||
"PACE",
|
||||
@@ -80,6 +94,7 @@ __all__ = [
|
||||
"SECTION_DONE",
|
||||
"SECTION_INCOMPLETE",
|
||||
"SECTION_STARTED",
|
||||
"STYLE",
|
||||
"WRITE_NODE",
|
||||
"BoundReviewNode",
|
||||
"ChapterState",
|
||||
@@ -87,10 +102,14 @@ __all__ = [
|
||||
"GatewayStream",
|
||||
"ReviewRecorder",
|
||||
"SseEvent",
|
||||
"build_character_gen_context",
|
||||
"build_outline_request",
|
||||
"build_precheck_context",
|
||||
"build_review_context",
|
||||
"build_review_graph",
|
||||
"build_review_request",
|
||||
"build_style_extract_request",
|
||||
"build_worldbuilder_context",
|
||||
"build_write_graph",
|
||||
"build_write_request",
|
||||
"collect_reviews",
|
||||
@@ -100,17 +119,23 @@ __all__ = [
|
||||
"extract_conflicts",
|
||||
"extract_foreshadow_sug",
|
||||
"extract_pace",
|
||||
"extract_style",
|
||||
"foreshadow_event",
|
||||
"make_review_node",
|
||||
"merge_reviews",
|
||||
"normalize_deltas",
|
||||
"normalize_review",
|
||||
"pace_event",
|
||||
"precheck_generated_cards",
|
||||
"run_character_gen",
|
||||
"run_outline",
|
||||
"run_review",
|
||||
"run_style_extraction",
|
||||
"run_worldbuilder",
|
||||
"section_event",
|
||||
"setup_checkpointer",
|
||||
"stream_chapter_draft",
|
||||
"style_event",
|
||||
"token_event",
|
||||
"write_node",
|
||||
]
|
||||
|
||||
@@ -11,11 +11,12 @@ AI 产出真正入库经验收事务的裁决)。
|
||||
collect 与 review_repo 同样**只 flush 不 commit**——`commit` 归 HTTP 端点 / 验收事务。
|
||||
本节点绝不 commit。
|
||||
|
||||
M3 三审齐 → 列映射(一次 record 落齐本章三审):
|
||||
M4 四审齐 → 列映射(一次 record 落齐本章四审):
|
||||
- continuity → `conflicts`(冲突清单 list);
|
||||
- foreshadow → `foreshadow_sug`(planted/resolved 扁平为建议 list,每条带 `kind` 标记);
|
||||
- pace → `pace`(节奏诊断 dict:water/hook/beat_map)。
|
||||
style 第四审留 M4。任一审 `incomplete`(网关失败隔离,§5.2)→ 该列留空/None,不阻塞其余。
|
||||
- pace → `pace`(节奏诊断 dict:water/hook/beat_map);
|
||||
- style → `style`(文风漂移诊断 dict:score/segments)。
|
||||
任一审 `incomplete`(网关失败隔离,§5.2)→ 该列留空/None,不阻塞其余。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -33,6 +34,7 @@ log = structlog.get_logger(__name__)
|
||||
CONTINUITY = "continuity"
|
||||
FORESHADOW = "foreshadow"
|
||||
PACE = "pace"
|
||||
STYLE = "style"
|
||||
|
||||
# foreshadow 建议扁平化时的来源标记(planted=新埋 / resolved=回收)。
|
||||
FORESHADOW_KIND_PLANTED = "planted"
|
||||
@@ -108,6 +110,19 @@ def extract_pace(reviews: dict[str, Any]) -> dict[str, Any] | None:
|
||||
return dict(result)
|
||||
|
||||
|
||||
def extract_style(reviews: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""从 style(第四审)分项取文风漂移诊断 dict(纯函数)。
|
||||
|
||||
`StyleDriftReview{score,segments}` 整体入 `style` 列(JSONB dict)。
|
||||
未完成/缺席 → None(列留空,§5.2 不阻塞其余)。无指纹降级(score=100/空段)
|
||||
仍是 `ok` 结果,照常落库(区别于 incomplete)。
|
||||
"""
|
||||
result = _ok_result(reviews, STYLE)
|
||||
if result is None:
|
||||
return None
|
||||
return dict(result)
|
||||
|
||||
|
||||
async def collect_reviews(
|
||||
state: ChapterState,
|
||||
*,
|
||||
@@ -124,12 +139,14 @@ async def collect_reviews(
|
||||
conflicts = extract_conflicts(reviews)
|
||||
foreshadow_sug = extract_foreshadow_sug(reviews)
|
||||
pace = extract_pace(reviews)
|
||||
style = extract_style(reviews)
|
||||
await review_repo.record(
|
||||
state["project_id"],
|
||||
state["chapter_no"],
|
||||
chapter_version=None,
|
||||
conflicts=conflicts,
|
||||
foreshadow_sug=foreshadow_sug,
|
||||
style=style,
|
||||
pace=pace,
|
||||
)
|
||||
log.info(
|
||||
@@ -139,6 +156,7 @@ async def collect_reviews(
|
||||
conflict_count=len(conflicts),
|
||||
foreshadow_sug_count=len(foreshadow_sug),
|
||||
has_pace=pace is not None,
|
||||
has_style=style is not None,
|
||||
reviews=sorted(reviews.keys()),
|
||||
)
|
||||
# 留痕已在表(真相源);不在节点 commit(端点/事务层负责,见模块 docstring)。
|
||||
|
||||
262
packages/core/ww_core/orchestrator/generation_node.py
Normal file
262
packages/core/ww_core/orchestrator/generation_node.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""生成节点(C6 扩 / ARCH §5.4 worldbuilder·character-gen 行 / §6.5 / §4.5)。
|
||||
|
||||
worldbuilder 与 character-gen 都是**独立生成**——不在写章/审稿流水线(不接进 review 图),
|
||||
仿 `outline_node` / `style_extract_node`。入库端点(T5.2)直接调本模块拿结构化产物,
|
||||
再持久化到 `world_entities` / `characters` 表。
|
||||
|
||||
三件事:
|
||||
1. `run_worldbuilder` — 据需求 + 作品设定产 `WorldGenResult`(硬规则显式)。
|
||||
2. `run_character_gen` — 据需求 + 世界观约束 + **已有角色 + 本批已生成卡**产 `CharacterGenResult`
|
||||
(群像防雷同:差异化上下文经 `build_character_gen_context` 注入,M5-a)。
|
||||
3. `precheck_generated_cards` — **入库前 continuity 校验缝**(ARCH §6.5):编排器在角色入库前
|
||||
追加一道 continuity 检查(**非 character-gen 直接互调**,守 §5.4 数据流),把生成卡 vs
|
||||
世界观/已有角色真相源比对、返回冲突清单。T5.2 入库端点调它做 gate。
|
||||
|
||||
确定性边界(CLAUDE.md「LangGraph」纪律):节点逻辑无 LLM 非确定性——不确定性藏在网关后,
|
||||
节点只做 `AgentSpec + 上下文 → LlmRequest` 的纯构造 + 转发 `Gateway.run` 的 `parsed`。
|
||||
注入 mock 网关(产 `parsed`)即可单测,无需图运行时、无需真 Postgres。
|
||||
|
||||
不变量 #2:agent 只声明 `tier`,绝不传具体 model(spec.tier 透传)。
|
||||
不变量 #3:节点**只读、不写库**——结构化产物返回调用方,落库经入库端点(T5.2)。
|
||||
不变量 #9:`spec.system_prompt` 进缓存断点前块(cache=True);注入材料进 `input`(断点后)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from typing import Protocol
|
||||
|
||||
import structlog
|
||||
from ww_agents import (
|
||||
AgentSpec,
|
||||
CharacterCard,
|
||||
CharacterGenResult,
|
||||
Conflict,
|
||||
ContinuityReview,
|
||||
WorldGenResult,
|
||||
)
|
||||
from ww_llm_gateway.types import Block, LlmRequest, LlmResponse, Scope
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class GatewayRun(Protocol):
|
||||
"""生成节点对网关的最小依赖——只需 `run`(注入真网关或 mock)。
|
||||
|
||||
模块内部用(每模块各自声明,不跨模块复用、不在 orchestrator `__init__` 重复导出,
|
||||
见 gotchas 2026-06-18)。
|
||||
"""
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse: ...
|
||||
|
||||
|
||||
def _build_request(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
context: str,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> LlmRequest:
|
||||
"""据 spec + 注入材料构造生成请求(纯函数,worldbuilder/character-gen 共用)。
|
||||
|
||||
`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),
|
||||
)
|
||||
|
||||
|
||||
# ---- worldbuilder ----
|
||||
|
||||
|
||||
def build_worldbuilder_context(*, brief: str, project_context: str) -> str:
|
||||
"""拼装 worldbuilder 注入文本(确定性,无时间戳)。"""
|
||||
return f"## 作品设定\n{project_context}\n\n## 世界观需求\n{brief}"
|
||||
|
||||
|
||||
async def run_worldbuilder(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
brief: str,
|
||||
project_context: str,
|
||||
gateway: GatewayRun,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> WorldGenResult:
|
||||
"""跑世界观生成:构请求 → `gateway.run` → 返回结构化 `WorldGenResult`。
|
||||
|
||||
独立生成(非并行审):网关失败直接上抛(端点/T5.2 处理),不静默吞。
|
||||
**只产结构化世界观、不写库**(持久化属 T5.2,不变量 #3)。
|
||||
"""
|
||||
context = build_worldbuilder_context(brief=brief, project_context=project_context)
|
||||
req = _build_request(spec, context=context, user_id=user_id, project_id=project_id)
|
||||
resp = await gateway.run(req)
|
||||
parsed = resp.parsed
|
||||
if not isinstance(parsed, WorldGenResult):
|
||||
log.error(
|
||||
"worldbuilder_node_missing_parsed",
|
||||
project_id=str(project_id),
|
||||
parsed_type=type(parsed).__name__,
|
||||
)
|
||||
raise ValueError("gateway returned no parsed WorldGenResult for worldbuilder request")
|
||||
return parsed
|
||||
|
||||
|
||||
# ---- character-gen(群像防雷同)----
|
||||
|
||||
|
||||
def _render_cards_for_context(cards: Sequence[CharacterCard]) -> str:
|
||||
"""把角色卡序列化为简表(name / role / traits)供防雷同对照与校验。
|
||||
|
||||
确定性(保入参顺序)、无时间戳/UUID。只取差异化判定相关的关键字段。
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for card in cards:
|
||||
traits = "、".join(card.traits) if card.traits else "(未列)"
|
||||
lines.append(f"- {card.name}({card.role}):{traits}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_character_gen_context(
|
||||
*,
|
||||
brief: str,
|
||||
count: int,
|
||||
role: str | None,
|
||||
world_context: str,
|
||||
existing_chars: Sequence[CharacterCard],
|
||||
generated_so_far: Sequence[CharacterCard],
|
||||
) -> str:
|
||||
"""拼装 character-gen 注入文本(含群像防雷同上下文,M5-a)。
|
||||
|
||||
注入「已有角色」+「本批已生成卡」,要求新卡与二者差异化(避免一群人一个模子)。
|
||||
确定性拼接、无时间戳——同输入同输出,便于单测与缓存。
|
||||
"""
|
||||
role_line = f"角色定位:{role}" if role else "角色定位:未指定(由你按需求判定)"
|
||||
existing_block = (
|
||||
_render_cards_for_context(existing_chars) if existing_chars else "(暂无已有角色)"
|
||||
)
|
||||
generated_block = (
|
||||
_render_cards_for_context(generated_so_far) if generated_so_far else "(本批尚无已生成卡)"
|
||||
)
|
||||
return (
|
||||
"## 世界观约束(取名/能力须契合的硬规则)\n"
|
||||
f"{world_context or '(暂无世界观设定)'}\n\n"
|
||||
"## 已有角色(新角色须与之区分;可与之建关系)\n"
|
||||
f"{existing_block}\n\n"
|
||||
"## 本批已生成卡(后续卡须与之差异化,防雷同)\n"
|
||||
f"{generated_block}\n\n"
|
||||
"## 本次生成需求\n"
|
||||
f"需求:{brief}\n"
|
||||
f"数量:{count}\n"
|
||||
f"{role_line}"
|
||||
)
|
||||
|
||||
|
||||
async def run_character_gen(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
brief: str,
|
||||
count: int,
|
||||
role: str | None,
|
||||
world_context: str,
|
||||
existing_chars: Sequence[CharacterCard],
|
||||
generated_so_far: Sequence[CharacterCard],
|
||||
gateway: GatewayRun,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> CharacterGenResult:
|
||||
"""跑角色/群像生成:构防雷同上下文 → `gateway.run` → 返回 `CharacterGenResult`。
|
||||
|
||||
群像防雷同(M5-a):注入「已有角色 + 本批已生成卡」要求差异化(§4.5 / §6.5)。
|
||||
独立生成(非并行审):网关失败直接上抛(端点/T5.2 处理),不静默吞。
|
||||
**只产结构化角色卡、不写库**——入库前过 `precheck_generated_cards` 校验、再经
|
||||
入库端点持久化(持久化属 T5.2,不变量 #3)。
|
||||
"""
|
||||
context = build_character_gen_context(
|
||||
brief=brief,
|
||||
count=count,
|
||||
role=role,
|
||||
world_context=world_context,
|
||||
existing_chars=existing_chars,
|
||||
generated_so_far=generated_so_far,
|
||||
)
|
||||
req = _build_request(spec, context=context, user_id=user_id, project_id=project_id)
|
||||
resp = await gateway.run(req)
|
||||
parsed = resp.parsed
|
||||
if not isinstance(parsed, CharacterGenResult):
|
||||
log.error(
|
||||
"character_gen_node_missing_parsed",
|
||||
project_id=str(project_id),
|
||||
parsed_type=type(parsed).__name__,
|
||||
)
|
||||
raise ValueError("gateway returned no parsed CharacterGenResult for character-gen request")
|
||||
return parsed
|
||||
|
||||
|
||||
# ---- 入库前 continuity 校验缝(ARCH §6.5 / §4.5 三关键能力之一)----
|
||||
|
||||
|
||||
def build_precheck_context(
|
||||
*,
|
||||
cards: Sequence[CharacterCard],
|
||||
world_context: str,
|
||||
characters_context: str,
|
||||
) -> str:
|
||||
"""拼装「入库前 continuity 校验」注入文本(确定性,无时间戳)。
|
||||
|
||||
校验材料 = 待入库的**生成角色卡** + 世界观硬规则 + 已有角色真相源——
|
||||
让 continuity 续审逐项比对,找出与既有设定/力量体系/已有角色的冲突。
|
||||
"""
|
||||
return (
|
||||
"## 待校验:本次生成的角色卡(尚未入库)\n"
|
||||
f"{_render_cards_for_context(cards)}\n\n"
|
||||
"## 真相源:世界观硬规则\n"
|
||||
f"{world_context or '(暂无世界观设定)'}\n\n"
|
||||
"## 真相源:已有角色\n"
|
||||
f"{characters_context or '(暂无已有角色)'}"
|
||||
)
|
||||
|
||||
|
||||
async def precheck_generated_cards(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
cards: Sequence[CharacterCard],
|
||||
world_context: str,
|
||||
characters_context: str,
|
||||
gateway: GatewayRun,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> list[Conflict]:
|
||||
"""入库前 continuity 校验(ARCH §6.5):跑 `continuity_spec` 比对生成卡 vs 真相源。
|
||||
|
||||
**编排器追加的一道检查**(非 character-gen 直接互调,守 §5.4 数据流/不变量 #1)。
|
||||
复用 `continuity_spec`(analyst 档,只读);返回冲突清单供 T5.2 入库端点做 gate
|
||||
(有冲突 → 提示作者裁决/调整,不静默入库)。
|
||||
|
||||
裸函数(显式 gateway 关键字),注入 mock 网关即可单测。校验失败语义同独立生成:
|
||||
网关失败直接上抛(端点处理);带 `output_schema` 时 parsed 必非 None(C1),违约报错。
|
||||
本缝**只读、不写库**(不变量 #3)。
|
||||
"""
|
||||
context = build_precheck_context(
|
||||
cards=cards,
|
||||
world_context=world_context,
|
||||
characters_context=characters_context,
|
||||
)
|
||||
req = _build_request(spec, context=context, user_id=user_id, project_id=project_id)
|
||||
resp = await gateway.run(req)
|
||||
parsed = resp.parsed
|
||||
if not isinstance(parsed, ContinuityReview):
|
||||
log.error(
|
||||
"precheck_node_missing_parsed",
|
||||
project_id=str(project_id),
|
||||
parsed_type=type(parsed).__name__,
|
||||
)
|
||||
raise ValueError("gateway returned no parsed ContinuityReview for precheck request")
|
||||
return list(parsed.conflicts)
|
||||
@@ -20,6 +20,7 @@ from ww_agents import (
|
||||
continuity_spec,
|
||||
foreshadow_spec,
|
||||
pace_spec,
|
||||
style_drift_spec,
|
||||
)
|
||||
|
||||
from .collect import ReviewRecorder, collect_reviews
|
||||
@@ -56,8 +57,13 @@ def build_write_graph(
|
||||
return builder.compile(checkpointer=checkpointer)
|
||||
|
||||
|
||||
# M3:三审齐(continuity + foreshadow + pace)。文风第四审属 M4/T4.2。
|
||||
REVIEW_SPECS: tuple[AgentSpec, ...] = (continuity_spec, foreshadow_spec, pace_spec)
|
||||
# M4:四审齐(continuity + foreshadow + pace + style)。第四审 = 文风漂移打分轨。
|
||||
REVIEW_SPECS: tuple[AgentSpec, ...] = (
|
||||
continuity_spec,
|
||||
foreshadow_spec,
|
||||
pace_spec,
|
||||
style_drift_spec,
|
||||
)
|
||||
|
||||
|
||||
def build_review_graph(
|
||||
|
||||
@@ -18,7 +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 .collect import CONTINUITY, FORESHADOW, PACE, STYLE
|
||||
from .review_node import REVIEW_OK
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
@@ -29,6 +29,7 @@ EVENT_SECTION = "section" # 四审分项开始/完成(审稿 review)
|
||||
EVENT_CONFLICT = "conflict" # continuity 冲突命中(审稿 review)
|
||||
EVENT_FORESHADOW = "foreshadow" # foreshadow 建议命中(新埋/回收,审稿 review)
|
||||
EVENT_PACE = "pace" # pace 节奏诊断(注水/钩子/节拍图,审稿 review)
|
||||
EVENT_STYLE = "style" # style 文风漂移诊断(整体相似度 + 漂移段,审稿 review)
|
||||
EVENT_DONE = "done"
|
||||
EVENT_ERROR = "error"
|
||||
|
||||
@@ -104,6 +105,17 @@ def pace_event(*, water: list[dict[str, object]], hook: bool, beat_map: list[int
|
||||
)
|
||||
|
||||
|
||||
def style_event(*, score: int, segments: list[dict[str, object]]) -> SseEvent:
|
||||
"""文风漂移事件(审稿 review)——前端 ◔ 整体相似度 + 漂移段列表(一键回炉)。
|
||||
|
||||
一次审产一条:整体相似度 score + 逐段漂移 segments(每段 idx/score/label)。
|
||||
"""
|
||||
return SseEvent(
|
||||
event=EVENT_STYLE,
|
||||
data={"score": score, "segments": segments},
|
||||
)
|
||||
|
||||
|
||||
def error_event(*, code: str, message: str, request_id: str | None = None) -> SseEvent:
|
||||
"""错误事件,形对齐错误信封 §7.1(`code`/`message`),附 `request_id` 便于贯通排查。"""
|
||||
return SseEvent(
|
||||
@@ -190,6 +202,20 @@ def _section_result_events(name: str, result: dict[str, Any]) -> list[SseEvent]:
|
||||
beat_map=[int(b) for b in result.get("beat_map") or []],
|
||||
)
|
||||
)
|
||||
elif name == STYLE:
|
||||
events.append(
|
||||
style_event(
|
||||
score=int(result.get("score", 100)),
|
||||
segments=[
|
||||
{
|
||||
"idx": int(seg.get("idx", 0)),
|
||||
"score": int(seg.get("score", 0)),
|
||||
"label": seg.get("label"),
|
||||
}
|
||||
for seg in result.get("segments") or []
|
||||
],
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
@@ -205,7 +231,8 @@ async def normalize_review(
|
||||
surface 结构化结果:
|
||||
- continuity → 每冲突一条 `conflict`(形对齐 C6 `Conflict`);
|
||||
- foreshadow → 每条建议一条 `foreshadow`(planted/resolved,前端看板联动);
|
||||
- pace → 一条 `pace`(注水/钩子/节拍图,前端节奏报告/▁▃▅)。
|
||||
- pace → 一条 `pace`(注水/钩子/节拍图,前端节奏报告/▁▃▅);
|
||||
- style → 一条 `style`(整体相似度 + 漂移段,前端 ◔ + 一键回炉)。
|
||||
末尾 `done{length=审项数}`。HTTP event-stream 编码归端点,不在此处。
|
||||
|
||||
错误处理纪律(同 `normalize_deltas`):任何意外 → 一条 `error` 事件后正常收尾,不上抛。
|
||||
|
||||
88
packages/core/ww_core/orchestrator/style_extract_node.py
Normal file
88
packages/core/ww_core/orchestrator/style_extract_node.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""文风提取节点(C6 扩 / ARCH §5.4 style-auditor 提取轨 / §6.9)。
|
||||
|
||||
文风提取是**独立生成**——不在写章/审稿流水线(不接进 review 图),仿 `outline_node`。
|
||||
端点(T4.3)经 BackgroundTask 调 `run_style_extraction` 拿结构化 `StyleFingerprintResult`,
|
||||
再持久化到 `style_fingerprint` 表(version 化)。
|
||||
|
||||
确定性边界(CLAUDE.md「LangGraph」纪律):
|
||||
- 节点逻辑里**无 LLM 非确定性**——不确定性藏在网关后;节点只做
|
||||
`AgentSpec + samples → LlmRequest` 的纯构造 + 转发 `Gateway.run` 的 `parsed`。
|
||||
- 因此注入 mock 网关(产 `parsed`)即可单测,无需图运行时、无需真 Postgres。
|
||||
|
||||
不变量 #2:agent 只声明 `tier`,绝不传具体 model(spec.tier 透传)。
|
||||
不变量 #3:节点**只读、不写库**——结构化指纹返回调用方,落库经端点(T4.3)。
|
||||
不变量 #9:`spec.system_prompt` 进缓存断点前块(cache=True);样本进 `input`(断点后)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Protocol
|
||||
|
||||
import structlog
|
||||
from ww_agents import AgentSpec, StyleFingerprintResult
|
||||
from ww_llm_gateway.types import Block, LlmRequest, LlmResponse, Scope
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class GatewayRun(Protocol):
|
||||
"""文风提取节点对网关的最小依赖——只需 `run`(注入真网关或 mock)。
|
||||
|
||||
模块内部用(每模块各自声明,不跨模块复用、不在 orchestrator `__init__` 重复导出,
|
||||
见 gotchas 2026-06-18)。
|
||||
"""
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse: ...
|
||||
|
||||
|
||||
def build_style_extract_request(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
samples_text: str,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> LlmRequest:
|
||||
"""据 spec + 样本正文构造文风提取请求(纯函数)。
|
||||
|
||||
`spec.system_prompt` → `system` 缓存断点前块(cache=True,不变量 #9);
|
||||
`samples_text`(拼接的样本正文)→ `input`(断点后)。
|
||||
提取用 `run()` 非 `stream()`(要结构化指纹,非流式正文)。
|
||||
"""
|
||||
return LlmRequest(
|
||||
tier=spec.tier, # 不变量 #2:只传档位,不传 model
|
||||
system=[Block(text=spec.system_prompt, cache=True)],
|
||||
input=samples_text,
|
||||
output_schema=spec.output_schema,
|
||||
scope=Scope(user_id=user_id, project_id=project_id),
|
||||
)
|
||||
|
||||
|
||||
async def run_style_extraction(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
samples_text: str,
|
||||
gateway: GatewayRun,
|
||||
user_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
) -> StyleFingerprintResult:
|
||||
"""跑文风提取:构请求 → `gateway.run` → 返回结构化 `StyleFingerprintResult`。
|
||||
|
||||
裸函数(显式 gateway 关键字),注入 mock 网关即可单测,无需图运行时。
|
||||
提取是独立生成(非并行审):网关失败直接上抛(端点/T4.3 处理),不静默吞。
|
||||
**只产结构化指纹、不写库**(持久化属 T4.3,不变量 #3)。
|
||||
"""
|
||||
req = build_style_extract_request(
|
||||
spec, samples_text=samples_text, user_id=user_id, project_id=project_id
|
||||
)
|
||||
resp = await gateway.run(req)
|
||||
parsed = resp.parsed
|
||||
if not isinstance(parsed, StyleFingerprintResult):
|
||||
# 带 output_schema 时 parsed 必非 None(C1);违约则明确报错而非返回空指纹。
|
||||
log.error(
|
||||
"style_extract_node_missing_parsed",
|
||||
project_id=str(project_id),
|
||||
parsed_type=type(parsed).__name__,
|
||||
)
|
||||
raise ValueError("gateway returned no parsed StyleFingerprintResult for style request")
|
||||
return parsed
|
||||
Reference in New Issue
Block a user