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:
Yaojia Wang
2026-06-20 10:39:58 +02:00
parent 5fb7bfb1de
commit 765dbdfbd4
161 changed files with 17330 additions and 208 deletions

View File

@@ -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",
]

View File

@@ -0,0 +1,121 @@
"""角色**写侧** RepositoryC3 扩 / 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_casefrozen"""
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 dictDB 列形)。空列表也包成 `{"items": []}` 形稳定。"""
return {"items": list(items)}
def _arc_to_jsonb(arc: str) -> dict[str, Any]:
"""弧光一句话 `str` → JSONB dictDB 列形)。"""
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 helperschema 字段名导出供测试/端点共用)----
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)

View 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_casefrozen——镜像 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:
"""创建一行 jobstatus=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

View File

@@ -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

View File

@@ -0,0 +1,49 @@
"""规则**写侧** RepositoryC3 扩 / 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_casefrozen"""
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)

View 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_casefrozen——含维度 + 证据 + 版本。
区别于 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 实现:版本化 INSERTappend-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,
)

View File

@@ -0,0 +1,75 @@
"""世界观实体**写侧** RepositoryC3 扩 / 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_casefrozen"""
model_config = {"frozen": True}
id: uuid.UUID
type: str
name: str
def _rules_to_jsonb(rules: list[str]) -> dict[str, Any]:
"""`list[str]` → JSONB dictDB 列形)。"""
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)

View File

@@ -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)

View File

@@ -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_idassemble 已在 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 repoT1.4 注入点)。"""
"""用一个 AsyncSession 装配全部 8 个 SQLAlchemy repoT1.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),
)

View File

@@ -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",
]

View File

@@ -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`(节奏诊断 dictwater/hook/beat_map
style 第四审留 M4。任一审 `incomplete`网关失败隔离§5.2)→ 该列留空/None不阻塞其余
- pace → `pace`(节奏诊断 dictwater/hook/beat_map
- style → `style`(文风漂移诊断 dictscore/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

View 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。
不变量 #2agent 只声明 `tier`,绝不传具体 modelspec.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 必非 NoneC1违约报错。
本缝**只读、不写库**(不变量 #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)

View File

@@ -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(

View File

@@ -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` 事件后正常收尾,不上抛。

View 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。
不变量 #2agent 只声明 `tier`,绝不传具体 modelspec.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 必非 NoneC1违约则明确报错而非返回空指纹。
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