101 KiB
契约登记(解耦缝)
跨 agent 的契约——多 agent 靠它解耦并行。契约先行:定义方先在此登记并标
稳定,依赖方才动工;改契约必须在此记一笔 + 在PROGRESS.md通知依赖任务(前端要重生成 TS 客户端、依赖模块要重同步)。 状态:待定义→草拟(@skill)→稳定→已变更(见日志)。
C1 · LLM 网关接口 owner @llm 状态: 稳定(T1.1, 2026-06-18)
- 来源:
ARCHITECTURE.md §4.1(LlmRequest/LlmResponse/Block/Usage,snake_case)。 - 消费方:编排器、所有 Agent(经
Gateway.run/Gateway.stream)。 - 关键不变量:agent 只传
tier(writer/analyst/light),不传具体 model。 - 已实现(
packages/llm_gateway/ww_llm_gateway):- 类型
types.py:Block(text,cache=False)、Scope(user_id,project_id?)、LlmRequest(tier,input:str|list[Block],system:list[Block],stream,output_schema?,max_tokens?,scope)、Usage(provider,model,input_tokens,output_tokens,cache_read_tokens,cost_minor,currency)、ServedBy(provider,model,fell_back)、LlmResponse(text,parsed?,usage,served_by)、Delta(text)、Tier。 Gateway(adapters:dict[str,ProviderAdapter], ledger:LedgerSink, resolver=resolve_route):async run(req)->LlmResponse、stream(req)->AsyncIterator[Delta]。每次调用落 1 条usage_ledger(经LedgerSink,可注入内存替身)。- 适配器
ProviderAdapter(Protocol):provider、capabilities()->Capabilities、async complete(req,model)->ProviderResult、stream(req,model)->AsyncIterator[StreamChunk]。M1 实现OpenAICompatAdapter(provider, client:AsyncOpenAI)(DeepSeek,注入 client 便于测试)。 - 档位路由
resolve_route(tier)->Route(provider,model)读config.tier_defaults(M1 仅全局默认;回退/熔断属 M5/T5.4,未实现)。 - 记账
SqlAlchemyLedgerSink(session)写UsageLedger(owner_id=scope.user_id 单用户 stub;列名cache_read)。成本表pricing.py(未知 provider/model→0)。
- 类型
C1 扩展(T5.4, 2026-06-19)· 多 provider 韧性(回退链 + 熔断 + 能力协商/降级) owner @llm 状态: 稳定
- 来源:
ARCHITECTURE.md §4.4(能力协商/降级)/§4.5(回退/重试/熔断)。仅加字段/形参,不破既有调用(旧Gateway(adapters, ledger, resolver=resolve_route)仍可用)。 ServedBy加字段(C1 形变,仅加可选 bool,默认 False,向后兼容):ServedBy(provider, model, fell_back=False, degraded=False)。degraded标能力降级(所选 provider 不支持原生结构化输出,改走 instructor JSON-提示路径)。→ OpenAPI 表面:ServedBy不直接出 API(review/draft SSE 不回 served_by,settings/providers 也不含它)→ 经核 本次无 OpenAPI 形变,前端无需 re-gen;若后续把served_by暴露到响应体,再触发 gen:api。Gateway构造扩(关键字,全可选):Gateway(adapters, ledger, *, chain_resolver: Callable[[Tier], list[Route]] | None=None, resolver: Callable[[Tier], Route] | None=None, max_retries=2, breaker: CircuitBreaker | None=None)。chain_resolver(回退链)优先;resolver(单路由,M1 兼容)被自动包成单元素链;皆缺省回退到默认resolve_chain。run/stream行为:沿链逐 provider→瞬时失败退避重试 R 次→仍失败/熔断打开/无适配器则切下一个;首个成功者服务,非链首即served_by.fell_back=True;链耗尽抛AppError(LLM_UNAVAILABLE)。记账记实际服务方 provider/model(回退后不记主模型)。流式仅在首块产出前可切(产出后中途失败上抛,不静默重连,§4.5)。- 回退链解析(routing.py):
resolve_chain(tier)->list[Route](默认仅全局tier_defaults,单元素);chain_from_routing(tier, primary:str, fallback:list[str])->list[Route](据 DBtier_routing行去重保序构链——供 apps/api 包成ChainResolver注入网关,apps/api 待接线:build_gateway_for_tier目前仍只注主 provider 适配器 +resolver=resolve_route,要启用回退须按 fallback 预备多个适配器 + 传chain_resolver)。ChainResolver = Callable[[Tier], list[Route]]类型别名导出。 - 熔断器:
CircuitBreaker(*, threshold=5, reset_seconds=30.0, clock=time.monotonic):is_open(provider)/record_failure/record_success;连续失败超阈值→短时熔断(网关直接跳过该 provider 走回退);成功清零;冷却窗口过半开放行。默认每个 Gateway 实例自带一个 breaker(进程内、非跨实例共享)——跨请求持久熔断需调用方注入共享 breaker。 - 瞬时错误契约:适配器把厂商 429/超时/5xx/连接错误翻译为
ww_llm_gateway.errors.TransientProviderError(OpenAI 兼容/Anthropic/Gemini 适配器均已包装,按异常类名 + status_code 判定);网关另把AppError(RATE_LIMITED)也视作可重试/可回退。非瞬时错误(内容策略拒绝等)原样上抛、不重试。 - 新适配器(均经注入的客户端 Protocol,测试不联网、不硬 import 厂商 SDK):
AnthropicAdapter(provider, client: AnthropicClient, *, structured_client?):capabilities()=(structured_output=True, prefix_cache=True, thinking=True);缓存断点经 system 块cache_control:{type:ephemeral}(§4.6);结构化经instructor.from_anthropic(懒构建)。GeminiAdapter(provider, client: GeminiClient):capabilities()=(structured_output=True, prefix_cache=False, thinking=True);结构化经config.response_mime_type=application/json + response_schema,文本回model_validate_json。OpenAICompatAdapter不变(新增瞬时错误包装,参与回退链)。
- 依赖:
packages/llm_gateway/pyproject.toml加tenacity>=8.2(已uv sync;原为 instructor 传递依赖)。anthropic/google-genaiSDK 已由 @devops 加(已uv sync)——适配器仍懒导入/注入客户端,单测零依赖。
C1 扩(CR-H4, 2026-07-08)· LlmRequest 加可选 request_id owner @llm 状态: 稳定
LlmRequest加字段(C1 形变,仅加可选、默认None,最后一位,向后兼容):request_id: str | None = None。调用方(apps/api/编排器)设置后,网关把它条件透传到每条llm_call/llm_provider_failed日志(req.request_id is not None才发——避免 None 覆盖merge_contextvars在 sync/SSE 路径供的 id),贯通端到端追踪(ARCH §9.3)。- 无 OpenAPI 形变、无需 gen:api:
LlmRequest是网关内部类型,不出现在packages/shared/schema.d.ts;前端无感。旧调用点全部无 request_id → 默认None,行为不变。
C1 扩 follow-up #2(2026-06-19)· build_adapter provider→适配器工厂 owner @llm 状态: 稳定
- 位置:
packages/llm_gateway/ww_llm_gateway/factory.py,经包__init__导出build_adapter。 - 签名(稳定,apps/api
build_gateway_for_tier据此逐 provider 建适配器进adaptersdict):def build_adapter(provider: str, *, api_key: str, base_url: str | None = None) -> ProviderAdapter。 - 分派:
provider=="anthropic"→AnthropicAdapter(懒 importAsyncAnthropic(api_key=, base_url?));provider in {"gemini","google"}→GeminiAdapter(懒 importgenai.Client(api_key=),忽略 base_url——SDK 无该形参);其余(deepseek/kimi/qwen/glm/openai…)→OpenAICompatAdapter(provider, AsyncOpenAI(api_key=, base_url=))。 - 工厂从 api_key 构真实客户端(适配器内部仍是注入客户端 Protocol,测试零联网);故单测只断按 provider 名选对适配器类 + provider 字段透传(
tests/test_build_adapter_factory.py,6 测),不联网。 - @backend 行动:
build_gateway_for_tier现可调build_adapter(provider, api_key=…, base_url=…)替换「一律OpenAICompatAdapter」,使回退链中的 anthropic/gemini 走对应专用适配器。无 OpenAPI 形变(served_by 不出 API),前端无需 re-gen。
C1 扩(K1.2, 2026-06-19)· Kimi Code 订阅 plan 适配器(伪造头 + OAuth bearer) owner @llm 状态: 稳定
- 位置:
packages/llm_gateway/ww_llm_gateway/adapters/kimi_code.py,经包__init__导出KimiCodeAdapter/build_kimi_code_client/kimi_code_headers/kimi_device_id/kimi_device_model/KIMI_CODE_BASE_URL/KIMI_CODE_USER_AGENT/KIMI_CLI_VERSION/KIMI_CODE_PLATFORM/KIMI_CODE_PROVIDER。 - provider 名:
kimi-code(与 API-key providerkimi分离,便于档位切换)。build_adapter("kimi-code", api_key=<access_token>, base_url=None)是 K1.3 接线的缝——api_key即 OAuth access token(OpenAI SDK 自动发Authorization: Bearer <token>),base_url缺省 →KIMI_CODE_BASE_URL。 - base URL:
https://api.kimi.com/coding/v1(OpenAI 兼容;KimiCodeAdapter子类化OpenAICompatAdapter,capabilities 继承:structured_output/prefix_cache=True)。 - model:
kimi-for-coding是路由/档位关注点,经.complete(req, model)传入,不在适配器硬编码。 - 必需伪造头 = 完整 7 头(opencode 规范):
User-Agent+ 6 个X-Msh-*。build_kimi_code_client把kimi_code_headers()作为AsyncOpenAI(default_headers=…),每次请求随客户端发出。 - 头集真源校正(2026-06-19)=
github.com/ooojustin/opencode-kimi(src/headers.tskimiHeaders()+src/constants.ts,1:1 镜像 kimi-cli v1.37.0)。早先曾误信picassio/pi-kimi-coder(仅 UA、不带 X-Msh-*)把头集裁成 UA-only——那是分歧/错误参考,已回退到 opencode 完整 7 头。coding API 校验全部 7 头,偏差→Moonshot 后端access_terminated_error: only available for Coding Agents(403)。7 头精确值:User-Agent=KimiCLI/1.37.0(f"KimiCLI/{KIMI_CLI_VERSION}";UA 前缀 =KimiCLI/<version>,version 必须 ==X-Msh-Version)。X-Msh-Platform=kimi_cli(字面常量字符串,非 OS 名)。X-Msh-Version=1.37.0(= UA 里的 CLI 版本)。X-Msh-Device-Name= 主机名 ASCII 化(socket.gethostname()/platform.node(),裁非 ASCII)。X-Msh-Device-Model=kimi_device_model():macOSf"macOS {platform.mac_ver()[0]} {platform.machine()}"(如"macOS 14.5 arm64");Windowsf"Windows {release} {machine}";其它f"{system} {release} {machine}"(platform.machine()原样,返回arm64/x86_64,不归一化)。X-Msh-Os-Version=platform.version()(OS 内核版本串,≈ Nodeos.version())。X-Msh-Device-Id= 稳定 UUID4 hex 无连字符(32 位小写)。kimi_device_id():envKIMI_DEVICE_ID优先 → 否则读/建~/.kimi/device_id(首次写一次uuid.uuid4().hex、之后复用;与 kimi-cli/opencode 共享路径)。跨调用/进程稳定,绝不每次随机。
- 单测(不联网,断构造出的客户端 base_url + default_headers(全 7 头键 + 固定字面值 + device-id 32 位小写 hex 稳定 + host 派生值非空 ASCII) + bearer + 工厂分派;env
KIMI_DEVICE_ID注入保证 CI 确定):tests/test_kimi_code_adapter.py+tests/test_kimi_code_factory.py。 - @backend 行动(K1.3):OAuth device 服务刷新 token 后,
_build_provider_adapter(build_gateway_for_tier)对 providerkimi-code调build_adapter("kimi-code", api_key=<当前 access_token>, base_url=…)即得带伪造头的适配器;token 刷新/获取归 K1.3,本适配器只收当前 access token。无 OpenAPI 形变,前端无需 re-gen。
C1 扩(kimi-code-key, 2026-06-20)· Kimi Code 订阅 plan 静态 Console Key(ToS 合规变体) owner @llm 状态: 稳定
- 位置:
packages/llm_gateway/ww_llm_gateway/adapters/kimi_code_key.py,经包__init__导出KimiCodeKeyAdapter/build_kimi_code_key_client/KIMI_CODE_KEY_PROVIDER。 - provider 名:
kimi-code-key(与 OAuth 的kimi-code、moonshot 的kimi均分离)。build_adapter("kimi-code-key", api_key=<console key>, base_url=None)走此分支。 - 与 OAuth
kimi-code的关键区别:build_kimi_code_key_client构造纯AsyncOpenAI(api_key=, base_url=KIMI_CODE_BASE_URL)——不设default_headers,即不伪造 UA、不带X-Msh-*头。Kimi Console(kimi.com/code/console)签发的 Key 走订阅额度、命中同一 coding 端点(https://api.kimi.com/coding/v1)+ 同 modelkimi-for-coding,但 plainAuthorization: Bearer <key>即可(openclaw + models.dev 确认,ToS 允许第三方 Key,仅 UA 篡改违规)→ 故这是干净/合规的默认路径(无封号风险,对照KimiCodeAdapter的伪造头变体)。 - 结构化输出:coding 端点
kimi-for-codingthinking 开启,与强制tool_choice互斥(live 400)。KimiCodeKeyAdapter复用kimi_code.build_kimi_code_structured_client(instructorMode.JSON)——同 OAuth 变体的 JSON-mode 修复。 - 单测(不联网):
tests/test_kimi_code_key_factory.py(5 测:工厂分派 + coding base + bearer + 无 X-Msh-*/无 KimiCLI UA + JSON-mode 结构化 + 显式 base_url 覆盖)。 - @backend 接线:
kimi-code-key是普通 api_key provider(auth_type="api_key",存api_key_enc,Fernet)——经既有build_gateway_for_tier/_build_provider_adapter/probe 路径无改动即可工作(只需provider_deps._PROVIDER_BASE_URLS注册 base_url)。不触发_build_provider_adapter的 OAuth 刷新分支(其判据auth_type==oauth or provider=="kimi-code"不命中本 provider)。无 OpenAPI 形变(用既有PUT /settings/providers),前端无需 re-gen。
C2 · DB schema(SQLAlchemy 模型) owner @db 状态: 待定义
- 来源:
ARCHITECTURE.md §3.1DDL(11 创作表 + chapter_reviews + 运营表;无向量列;users stub)。 - 消费方:
@backend(Repository/记忆服务/验收)、@llm(Agent reads/writes)。
C2 扩展(K1.1, 2026-06-19)· provider_credentials 加 OAuth 列 owner @db 状态: 稳定
- 背景:K1 Kimi Code OAuth(订阅 plan device-flow)。
provider_credentials表新增两列 + 改api_key_enc可空,使一行可表「api_key 凭据」或「oauth 凭据」二选一。 - 模型变更(
packages/db/ww_db/models.pyProviderCredential):- 新增
auth_type: Mapped[str]=Text, nullable=False, server_default="'api_key'"(值域"api_key"|"oauth")。既有行迁移后默认'api_key',向后兼容。 - 新增
oauth_enc: Mapped[bytes | None]=LargeBinary, nullable=True。持 Fernet 加密的 JSON 包{access_token, refresh_token, expires_at}(@backend K1.3 写/读:encrypt/decrypt,key 取settings.credential_enc_key,同api_key_enc的 Fernet)。 api_key_enc由nullable=False改nullable=True(Mapped[bytes | None])——OAuth-only 行无 api_key;既有 api_key 行迁移后仍保留其值。UniqueConstraint("owner_id","project_id","provider")不变(OAuth provider 用独立 provider 名kimi-code,与 api_key 的kimi分离,不撞约束)。
- 新增
- 迁移:
packages/db/migrations/versions/1f011c42bd4d_provider_credentials_oauth_columns.py(down_revision=220ca2e3d53f)。up: add_column auth_type/oauth_enc + alter_column api_key_enc→nullable;down 逆向。alembic check无漂移。 - ⚠️ K1.3 @backend 必读:
api_key_enc现为bytes | None,apps/api/ww_api/services/credentials.py的StoredCredential.api_key_enc: bytes(dataclass 字段 line 28)与 store 的r.api_key_enc赋值(line 75/97)mypy 报arg-type(bytes | None↳bytes)。K1.1 不动 @backend 文件(目录所有权);K1.3 接 OAuth store 时须把StoredCredential.api_key_enc改成bytes | None(并加auth_type/oauth_enc字段/读写路径),届时 mypy 即绿。当前全仓 mypy 唯余此 2 处错(都在 credentials.py),其余门禁绿(ruff/format/alembic check/pytest 400 passed)。
C3 · API / OpenAPI 端点 owner @backend 状态: 稳定(M1 端点全落:T1.7 settings + T1.4 projects/chapters, 2026-06-18)
- 来源:
ARCHITECTURE.md §7.2端点清单(章节端点统一/projects/:id/chapters/:no/...;含PUT .../draft(自动保存)、/refine、/jobs/:id、/reviews)。 - 消费方:
@frontend(经 OpenAPI→TS 客户端)。改端点/字段 → 前端必须pnpm gen:api重生成客户端。 - 已落(T1.7, 2026-06-18,snake_case,响应仅脱敏 key):
GET /settings/providers→ProvidersResponse{ providers:[ProviderView{provider, masked_key}], tier_routing:[TierRoutingView{tier, provider, model, fallback:list[str]}] }PUT /settings/providers←ProvidersUpsertRequest{ credentials:[ProviderCredentialInput{provider, api_key}], tier_routing:[TierRoutingInput{tier, provider, model, fallback}] }→ProvidersResponse(脱敏)POST /settings/providers/test←TestConnectionRequest{provider}→TestConnectionResponse{provider, ok, capabilities:CapabilitiesView{structured_output, prefix_cache, thinking}}- 明文 key 永不过边界;probe 经可注入
ProviderProbe(测试用 fake,不联网)。
- 已落(T1.4, 2026-06-18,snake_case):
POST /projects←ProjectCreateRequest{title, genre?, logline?, premise?, theme?, selling_points:list, structure?}→ 201ProjectResponse{id, title, genre?, logline?, premise?, theme?, selling_points, structure?}GET /projects→ProjectListResponse{projects:[ProjectResponse]};GET /projects/{id}→ProjectResponse(404NOT_FOUND信封)POST /projects/{id}/chapters/{no}/draft→ SSEtext/event-stream,帧event:<token|done|error>\ndata:<json>\n\n(token{text}/done{length}/error{code,message,request_id});无凭据 → 流前LLM_UNAVAILABLE(503) JSON 信封(非帧)。PUT /projects/{id}/chapters/{no}/draft←DraftSaveRequest{text}→ 200DraftResponse{project_id, chapter_no, volume, status, version, length}(幂等:version 固定 1、status='draft')。- 网关注入缝
get_writer_gateway(从凭据构OpenAICompatAdapter+SqlAlchemyLedgerSink);测试经app.dependency_overrides注 mock 网关(只需.stream(req))。 - @frontend 行动:M1 端点已全在 OpenAPI → Wave D 前先
cd apps/web && pnpm gen:api重生成lib/api/schema.d.ts。
C3 扩展(项目列表元数据, 2026-06-28)· ProjectResponse 增加最近编辑与待审稿统计 owner @backend 状态: 稳定
ProjectResponse追加字段:updated_at: datetime | null、pending_review_count: int >= 0。创建、列表、详情响应共用同一 schema;前端已pnpm gen:api。updated_at口径:取项目自身projects.updated_at与该项目下章节chapters.updated_at、审稿留痕chapter_reviews.created_at的最大值,用于作品库“最近编辑”排序。为支持草稿重复保存后的真实编辑时间,chapters表新增updated_at列(迁移8c1d2e3f4a5b,既有行以created_at回填)。pending_review_count口径:按章节草稿行计数;当草稿updated_at晚于该章最近 accepted 版本和最近审稿留痕时,视为待审稿。已验收且草稿未再更新的章节不计入。- @frontend 已消费:作品库增加“待审稿”筛选、“最近编辑”排序,作品卡显示最近编辑时间和待审稿徽标。
- 已落(T2.4+T2.5, 2026-06-18,snake_case,全部挂
projects.router、已在 OpenAPI)— M2 审/裁/验收三端点:POST /projects/{id}/chapters/{no}/review←ReviewRequest{draft?}(空/缺→回退已存草稿;无草稿→404NOT_FOUND)→ SSEtext/event-stream,帧:section{name,status:"done"|"incomplete"}(每审一条,M2 仅continuity) /conflict{type,where,refs:list,suggestion}(每冲突一条,形=C6Conflict五类) /done{length=审项数}/error{code,message,request_id}。无凭据→流前LLM_UNAVAILABLE(503 JSON 信封)。续审网关 tier=analyst(get_review_gateway)。端点流耗尽后session.commit()(网关 ledger + collect 均只 flush)。GET /projects/{id}/chapters/{no}/reviews→ReviewHistoryResponse{reviews:[ReviewHistoryItem{id,project_id,chapter_no,chapter_version?,conflicts:list,foreshadow_sug:list,style?,pace?,health_score?,decisions?}]}(新→旧)。POST /projects/{id}/chapters/{no}/accept←AcceptRequest{final_text(min1), decisions:[ConflictDecision{conflict_index:int>=0, verdict:"accept"|"ignore"|"manual", note?}]}→AcceptResponse{project_id,chapter_no,accepted_version:int,digest_added:bool,decisions_recorded:int,review_id?:uuid}。冲突 gate:裁决的conflict_index集合须覆盖range(len(最近一条 review.conflicts)),缺判→409CONFLICT_UNRESOLVED+details.missing_conflict_indices/conflict_count;无留痕或零冲突→直通。digest 提炼 tier=light(get_digest_gateway)在事务外(R2),单事务 promote(R4)+digest.append(#4)+set_decisions 末尾一次 commit(R3)。- 注入缝:
build_gateway_for_tier(session, store, tier)(原build_writer_gateway退化为 writer 特例) +get_review_gateway/get_digest_gateway/get_review_repo/get_digest_append_repo;测试经app.dependency_overrides注 mock。 - @frontend 行动:
cd apps/web && pnpm gen:api重生成客户端含此三端点(T2.6)。
- 已落(T3.2, 2026-06-18,snake_case)— 伏笔登记/状态端点 + 验收后到期扫描(挂
foreshadow.router,prefix/projects,tagforeshadow,已注册):POST /projects/{id}/foreshadow←ForeshadowRegisterRequest{code(min1),title(min1),planted_at?,content?,expected_close_from?,expected_close_to?,importance?}→ 201ForeshadowView{code,title,status,planted_at?,content?,expected_close_from?,expected_close_to?,importance?,links:list,progress:list}(status=OPEN)。重复 code(DB 唯一(project_id,code))→ 422VALIDATION+details{field:"code",code,reason:"duplicate"}。PATCH /projects/{id}/foreshadow/{code}←ForeshadowTransitionRequest{to_status?,progress_entry?}→ 200ForeshadowView。非法转移→422VALIDATION+details{reason:"invalid_transition"};二者都缺→422empty_update;code 不存在→404NOT_FOUND。先转移后追加进展,端点写后commit()(repo 只 flush)。- 错误码取舍:重复/非法转移映射
VALIDATION(422) 非 409——现有唯一 409 码CONFLICT_UNRESOLVED语义专指未决冲突禁验收,不复用;未动packages/shared/errors.py契约。若 T3.5/前端需真 409 须先加专用码。 - 验收后到期扫描:
POST .../accept单事务 commit 成功后经BackgroundTasks登记run_overdue_scan(session_factory, project_id, chapter_no=刚验收章号)——自建独立 session(请求 session 已关闭)、应用current_ch>expected_close_to AND status≠CLOSED→OVERDUE、有变更才 commit;日志foreshadow_overdue_scan(带 request_id/overdue_count/codes),失败吞不冒泡。§7.4 持久性局限(进程内/重启丢任务)原型接受、不引 jobs 表。 - T3.5 续接:
GET /projects/{id}/foreshadow?status=看板加到本foreshadow.router(注入get_foreshadow_repo→list_by_status,建议响应包{foreshadow:[...]});POST .../outline落独立routers/outline.py(别混进 foreshadow.py),调run_outline(C6 扩)+build_gateway_for_tier(...,"analyst"),逐章 upsertoutline表、端点末尾 commit。 - @frontend 行动:T3.6 前
pnpm gen:api纳入伏笔登记/状态端点(+ T3.5 看板/outline)。
- 已落(T3.5, 2026-06-18,snake_case)— 伏笔看板 + 大纲生成端点:
GET /projects/{id}/foreshadow?status=(tagforeshadow):querystatus?∈OPEN|PARTIAL|CLOSED|OVERDUE(缺省=全部,非法→422VALIDATION+details{field:"status",reason:"invalid_status"}) → 200ForeshadowBoardResponse{foreshadow:list[ForeshadowView]}(按 code 升序)。POST /projects/{id}/outline(tagoutline,独立routers/outline.py)←OutlineGenerateRequest{volume:int=1,ge=1}(M3 简单分卷:整批同卷)→ 200OutlineResponse{chapters:list[OutlineChapterView{no,volume,beats:list[str],foreshadow_windows:list[ForeshadowWindowView{code,plant_chapter?,expected_close_from?,expected_close_to?}]}]};项目不存在→404NOT_FOUND;无凭据/网关失败→503LLM_UNAVAILABLE(流前 JSON 信封)。调run_outline(analyst 网关get_outline_gateway)→逐章 upsertoutline表(写侧SqlOutlineWriteRepo,只 flush)→端点末尾commit()(含 1 条 usage_ledger)。- beats 存储形:DB
outline.beats是 JSONB dict→写侧包成{"beats":[...]}落库,API 出参OutlineChapterView.beats解包成裸list[str]。 - @frontend 行动:T3.6
pnpm gen:api纳入看板/大纲端点。
C3 扩展(T4.3, 2026-06-19)· 文风端点:学文风(jobs 异步) + 回炉 + 最新指纹(独立 routers/style.py,已注册)
- snake_case;改字段 → @frontend 必须
cd apps/web && pnpm gen:api重生成 TS 客户端(T4.4 前置)。 POST /projects/{id}/style←StyleLearnRequest{samples:list[str](min_length=1), mode:Literal["create","update"]="create"}→ 202StyleLearnResponse{job_id:uuid}。项目不存在→404NOT_FOUND;无凭据→503LLM_UNAVAILABLE(在get_style_extract_gatewaydep 解析阶段拦下,调度 job 之前,不凭空写注定失败的 job)。流程:job_repo.create(project_id,"style_learn")→session.commit()(job 行在 202 前持久化供轮询)→background_tasks.add_task(run_job, session_factory, job.id, work)。work在run_job自建独立 session 上自造 analyst 网关(build_gateway_for_tier(session, SqlCredentialStore(session), "analyst"))+ 写侧 repo →run_style_extraction(style_extract_spec, samples_text="\n\n".join(samples), ...)→ 拆dimensions_json={name:value}/evidence_json={name:[evidence]}→SqlStyleFingerprintWriteRepo.append(version+1)→ 返回{version, dims_count}(落jobs.result)。run_job拥有 session 生命周期/commit(业务写 + job done 同一事务)。mode不改落库逻辑(写侧始终 append 新版本),仅供前端区分首学/更新语义。前端经GET /jobs/{job_id}轮询。GET /projects/{id}/style→ 200StyleFingerprintResponse{dimensions:dict, evidence:dict, version:int}(最新版本完整指纹,UX §6.9)。项目不存在→404;无指纹→404NOT_FOUND。新增端点(ARCH §7.2 原缺读指纹端点,见 decisions 2026-06-19)。读侧用写侧 repo 的latest(含 evidence/version),不动 C5 读侧SqlStyleRepo.latest/StyleView(只 dimensions,assemble 用),故 assemble/C5 行为不变。POST /projects/{id}/chapters/{no}/refine←RefineRequest{segment:str(min_length=1), instruction:str|None=None}→ 200RefineResponse{original:str, refined:str}。同步回炉:writer 网关(get_refine_gateway)run(refiner_spec),output_schema=None→ 取resp.text为 refined;输入 =【待重写段落】{segment}+ 可选【改写指令】{instruction}。不写库(不变量 #3,作者采纳经既有 draft 自动保存合入);末尾session.commit()(网关 ledger add-only,否则 usage_ledger 静默丢失,同 draft/review 纪律)。项目不存在→404;无凭据→503。- 注入缝(
services/project_deps.py):get_style_write_repo(SqlStyleFingerprintWriteRepo,服务GET /style读侧)、get_style_extract_gateway(analyst,学文风的凭据探测缝)、get_refine_gateway(writer)。测试经app.dependency_overridesoverride;学文风后台路径 monkeypatchstyle.build_gateway_for_tier/style.SqlStyleFingerprintWriteRepo+job_runner.SqlJobRepo(run_job自建 repo,不走 dep)。 - 写侧 repo(
packages/core/ww_core/domain/style_repo.py,经ww_core.domain导出):StyleFingerprintWriteRepo/SqlStyleFingerprintWriteRepo(加Write避撞 C5 读侧SqlStyleRepo,仿DigestAppendRepo先例):append(project_id, *, dimensions_json, evidence_json)->int(version=当前 max+1,首次 1;只 flush 不 commit)+latest(project_id)->StyleFingerprintView|None(含 dimensions/evidence/version,供GET /style)。 - @frontend 行动:T4.4 前
pnpm gen:api纳入 style/refine/GET style + jobs 轮询类型。
C3 扩展(T5.5, 2026-06-19)· 规则端点 POST /projects/:id/rules(独立 routers/rules.py,已注册)
- snake_case;改字段 → @frontend 必须
cd apps/web && pnpm gen:api(T5.6 前置)。 POST /projects/{project_id}/rules←RuleCreateRequest{level:Literal["global","genre","style","project"], content:str(min_length=1)}→ 201RuleView{level:str, content:str}。非法level/ 空content→ FastAPI 422(Pydantic Literal/min_length 校验,非 AppError 信封)。- 加规则是作者显式动作(不变量 #3:不经 AI 静默写库)。写一行
rules(绑project_id),喂给 assemble 的四级合并merge_rules(global→genre→style→project)。 - 提交边界:
RuleWriteRepo.create只flush(),端点写后await session.commit()(仿 foreshadow/outline 写侧)。 - 写侧 repo(
packages/core/ww_core/domain/rule_repo.py,经ww_core.domain导出):RuleWriteRepo(Protocol)/SqlRuleWriteRepo/RuleWriteView{project_id?,level,content}(加Write避撞 C5 读侧domain.repositories.RuleView{level,content},仿OutlineWriteRepo先例)。 - 注入缝(
services/project_deps.py):get_rule_write_repo(SqlRuleWriteRepo)。测试经app.dependency_overridesoverride 注 fake repo + fake session(断 commit 计数)。 - @frontend 行动:T5.6 前
pnpm gen:api纳入POST /rules(规则页用)。
C3 扩展(T5.2, 2026-06-19)· 生成/入库 + 读端点(独立 routers/generation.py,已注册) owner @backend 状态: 稳定
- snake_case;改字段 → @frontend 必须
cd apps/web && pnpm gen:api(T5.6 前置)。生成走即时返回(预览→作者确认→入库,M5-d,非 jobs)。 POST /projects/{project_id}/world/generate←WorldGenerateRequest{brief:str(min1)}→ 200WorldGenPreviewResponse{entities:[WorldEntityCardView{type:str,name:str,rules:list[str]}]}。预览不入库(worldbuilder writer 网关run_worldbuilder);末尾commit()仅落网关 ledger。项目不存在→404;无凭据→503LLM_UNAVAILABLE(get_worldbuilder_gatewaydep 解析阶段拦下)。POST /projects/{project_id}/characters/generate←CharacterGenerateRequest{brief:str(min1), count:int=1(ge1,le12), role:str|None, role_mix:dict[str,int]|None}→ 200CharacterGenPreviewResponse{cards:[CharacterCardView{name,role,traits:list[str],backstory,arc:str,speech_tics:list[str],tags:list[str],relations:[CharacterRelationView{name,kind,note?}]}]}。预览不入库(character-gen writer 网关,注入已有角色防雷同,generated_so_far=[]);末尾 commit 落 ledger。404/503 同上。⑦ 群像按定位配比:role_mix({定位:数量})在场时count取各值之和(忽略客户端 count),每项≥1、总和≤12(model_validator归一化);缺省退回单一role+count。POST /projects/{project_id}/characters(入库)←CharacterIngestRequest{cards:[CharacterCardView](min1), acknowledge_conflicts:bool=false}→ 201CharacterIngestResponse{created:list[str], rejected_tables:list[str]}。入库 gate:①precheck_generated_cards(continuity 预检, analyst 网关) 比对卡 vs 世界观/已有角色真相源——有冲突且acknowledge_conflicts=false→ 409CONFLICT_UNRESOLVED+details{conflicts:[{type,where,refs,suggestion}], conflict_count}(仿 accept gate,不静默入库,不变量#3;acknowledge_conflicts=true即作者裁决放行)。②partition_writes(character_gen_spec, {"characters":cards})白名单过滤(越权表→rejected_tables审计 log+丢弃,character-gen 只声明 writes=characters 故正常为空)。③ 写characters行(schema list/str → DB JSONB dict 形变,见写侧 repo)。404/503 同上。提交边界:precheck 网关 ledger + 角色写侧均只 flush → 端点末尾一次commit()(含冲突 409 路径也 commit 落 precheck usage)。GET /projects/{project_id}/rules→ 200RuleListResponse{rules:[RuleView{level,content}]}(规则页;复用 C5 读侧SqlRulesRepo.all_for_project,不动 assemble)。GET /skills(独立skills_router,prefix/skills)→ 200SkillListResponse{skills:[SkillView{name,scope,tier,reads:list[str],writes:list[str],genre?}]}(技能库 UI;经get_skill_registry读 registry,按 name 升序)。- 写侧 repo(
packages/core/ww_core/domain/,经ww_core.domain导出):CharacterWriteRepo/SqlCharacterWriteRepo/CharacterWriteView{id,name,role}(character_repo.py)+WorldEntityWriteRepo/SqlWorldEntityWriteRepo/WorldEntityWriteView{id,type,name}(world_entity_repo.py,预留对称入库)。形变:traits/speech_tics(list)→{"items":[...]}、arc(str)→{"text":...}、rules(list)→{"rules":[...]};tags/relations→直落 JSONB list(仅 character 入库端点已落地;world ingest 端点本期未建,仅 worldbuilder 走预览)。 - 注入缝(
services/project_deps.py):get_worldbuilder_gateway(writer)/get_character_gen_gateway(writer)/get_precheck_gateway(analyst)/get_character_write_repo/get_world_entity_write_repo/get_rules_read_repo。测试经app.dependency_overrides注 fake repo + schema-routing fake 网关(按req.output_schema返 WorldGenResult/CharacterGenResult/ContinuityReview)+ fake session(断 commit 计数)。 build_gateway_for_tier多 provider 接线(T5.4 follow-up,已完成):据 DBtier_routing取该 tier 的provider:model+fallback,为每个可建适配器的 provider 预备适配器,注入chain_resolver=chain_from_routing(...)启用回退链。无 DB 路由行 → 退回全局resolve_route(单 provider,M1 兼容);无任何可用凭据 → 503。单 provider 配置 = 单元素链(行为不变)。build_adapterper-provider 接线(M5 R1 follow-up #2,2026-06-19,已完成):_build_provider_adapter(原_build_openai_compat_adapter)现调ww_llm_gateway.build_adapter(provider, api_key=…, base_url=…)(C1 扩 follow-up #2 工厂)替代「一律OpenAICompatAdapter」——OpenAI 兼容 provider(deepseek/kimi/qwen/glm/openai)经_PROVIDER_BASE_URLS传 base_url;Anthropic/Gemini 传base_url=None走原生适配器。这样tier_routing链里配置的 Anthropic/Gemini provider 也能拿到真实适配器参与回退(不再被 base_url 缺失跳过)。未配凭据的 provider 返 None(回退链跳过)。无 OpenAPI 形变(served_by 不出 API)。真实 Anthropic/Gemini 跑通仍需 @devops 加anthropic/google-genaiSDK 依赖(适配器懒导入/注入客户端)。- @frontend 行动:T5.6 前
pnpm gen:api纳入 world/characters generate + characters ingest + GET rules + GET skills。
C3 扩展(M5 R1 follow-up, 2026-06-19)· 设定库 Codex 读端点(GET characters / world_entities) owner @backend 状态: 稳定
- 补 PROGRESS「Codex 读端点缺口」余项:设定库 Codex 现可展示跨会话全量已入库角色/世界观(前端先前只能「生成+本次入库会话内回显」)。snake_case;新增端点 → @frontend 必须
cd apps/web && pnpm gen:api重生成 TS 客户端 + CodexPage 初始列表去掉「会话内」局限(见 gotcha 2026-06-19 @frontend Codex 缺口条)。 GET /projects/{project_id}/characters→ 200CharacterListResponse{characters:[CharacterCardView]}(已入库角色全量;复用 C5 读侧SqlCharacterRepo,经get_memory_repos的memory.character.list_for_project,不动 assemble)。无行 → 空列表(非 404)。GET /projects/{project_id}/world_entities→ 200WorldEntityListResponse{world_entities:[WorldEntityCardView]}(已入库世界观实体全量;复用 C5 读侧SqlWorldEntityRepo)。- 形变(DB JSONB→API,T5.2 ingest 形变的逆向):
characters.traits/speech_ticsdict{"items":[...]}→裸 list、arcdict{"text":...}→str、tags/relations→直落 list(复用既有_existing_charactershelper);world_entities.rulesdict{"rules":[...]}→裸 list。 - 路由:挂既有
generation.router(prefix/projects);GET/{project_id}/characters与 POST 同路径,FastAPI 按 method 区分,无冲突。注入缝复用get_memory_repos(无新 dep)。测试经app.dependency_overrides[get_memory_repos]注 fakeMemoryRepos。 - @frontend 行动:
pnpm gen:api纳入GET /projects/:id/characters+GET /projects/:id/world_entities;lib/api/server加fetchCharacters/fetchWorldEntities+ CodexPage 初始列表拉全量。
C3 扩展(大纲读端点 follow-up, 2026-06-20)· GET /projects/{id}/outline(挂既有 routers/outline.py,已注册) owner @backend 状态: 稳定
- 补缺口:大纲页先前只有
POST .../outline(生成+持久化),无读端点 → 重访页面已落库的大纲不回显,看似「未保存」。仿 Codex 读端点(GET characters/world_entities)补对称读侧。 GET /projects/{project_id}/outline(tagoutline)→ 200OutlineResponse{chapters:list[OutlineChapterView{no,volume,beats:list[str],foreshadow_windows:list[ForeshadowWindowView]}]}(与POST .../outline响应同形,前端类型对齐),按chapter_no升序。项目存在但无大纲 → 200 空列表(非 404);项目不存在 → 404NOT_FOUND(仿 POST 的项目存在性检查)。只读不写库。- 复用 C5 读侧:扩
OutlineRepo(domain protocol) +SqlOutlineRepo(C5 assemble 读侧 repo) 加list_for_project(project_id)->list[OutlineView](仿CharacterRepo/WorldEntityRepo命名,order_by chapter_no);既有get(project_id, chapter_no)不动,assemble 行为不变。新注入缝get_outline_read_repo(services/project_deps.py,仿get_rules_read_repo)。 - beats 形变:DB
outline.beatsJSONB dict{"beats":[...]}→ 出参OutlineChapterView.beats解包成裸list[str](同 POST 路径 /OutlineWriteView._to_view)。 - @frontend 行动:
pnpm gen:api纳入GET /projects/:id/outline;大纲页初次加载拉已持久化大纲(去掉「生成后才显示」局限)。
C3 扩展(草稿读端点 follow-up, 2026-06-20)· GET /projects/{id}/chapters/{no}/draft(挂既有 routers/projects.py,已注册) owner @backend 状态: 稳定
- 补缺口:写作工作台先前只有
POST .../draft(SSE 流写) +PUT .../draft(自动保存) 无读端点 → 重访工作台时编辑器空白、看似「未保存」(其实chapters行在库)。仿GET .../outline补对称读侧。 GET /projects/{project_id}/chapters/{chapter_no}/draft→ 200DraftView{project_id, chapter_no, volume, status, version, content, length}(比 PUT 的DraftResponse多content——读侧需正文重建编辑器;length=content 字符数派生)。无草稿行或正文空白 → 404NOT_FOUND(工作台据此呈现空编辑器,与其它「缺资源」端点一致)。只读不写库。- 复用读 seam:直接调既有
chapter_repo.get_draft(project_id, chapter_no)->ChapterDraftView|None(同续审_resolve_review_draft的读缝);ChapterDraftView已含全部字段,无需新 repo 方法/新 view。多版本时固定返草稿版次(DRAFT_VERSION=1)那条可编辑工作副本,与 PUT 保存的同一行。 - @frontend 行动:
pnpm gen:api纳入GET /projects/:id/chapters/:no/draft(新 schemaDraftView);工作台初次加载拉已保存草稿回灌编辑器(去掉「重访看似未保存」局限)。
C3 扩展(T4-b, 2026-06-20)· POST .../draft 接收可选本章指令 directive(挂既有 routers/projects.py) owner @backend 状态: 稳定
- 仅加可选 body,向后兼容:
POST /projects/:id/chapters/:no/draft现接收可选 JSON bodyDraftStreamRequest{directive: str|None=None}(无 body 的旧调用方不变,仍按大纲生成)。directive=作者本章指令,临时输入、不持久化、无迁移。 - 直通
assemble(..., directive=…)→_build_volatile以「本章指令」段领衔 volatile(断点后),绝不入 stable_core(守缓存前缀不变量 #9)。draft_stream_start日志加directive_len(不记原文,脱敏)。 - @frontend 行动:
pnpm gen:api纳入DraftStreamRequest;useDraftStream.start(projectId, chapterNo, directive?)非空时 POST JSON body 传 directive。
C3 扩展(K1.3, 2026-06-19)· Kimi Code OAuth device-flow 端点(独立 routers/kimi_oauth.py,已注册) owner @backend 状态: 稳定
- snake_case;新增 3 端点 + 3 schema → @frontend 必须
cd apps/web && pnpm gen:api(K1.4 前置)。响应/job 结果/日志绝不含 access/refresh token(只 user_code/connected/expires_at 等非密信息);token 仅以 Fernet 密文存provider_credentials.oauth_enc。 POST /settings/providers/kimi-code/oauth/start→ 202OAuthStartResponse{job_id:uuid, user_code:str, verification_uri:str, verification_uri_complete:str|None, expires_in:int, interval:int}。流程:start_device_authorization(http)→job_repo.create(None,"kimi_oauth")+session.commit()(202 前持久化供轮询)→background_tasks.add_task(run_job, session_factory, job.id, _make_poll_work(device))。后台work在run_job自建独立 session 上循环poll_token(authorization_pending→续轮询、slow_down→增大 interval、expired/denied→抛 AppError 置 job failed);成功 →upsert_oauth_credential(加密包)+ job done(结果只{connected:true, provider})。前端展示 user_code + 打开 verification_uri + 轮询GET /jobs/{id}。POST /settings/providers/kimi-code/oauth/disconnect→ 200OAuthDisconnectResponse{disconnected:bool}(delete_credential清 OAuth 凭据行)。GET /settings/providers/kimi-code/oauth/status→ 200OAuthStatusResponse{connected:bool, expires_at:str|None}(解密oauth_enc取过期时刻;无 token 本体;解密失败/无凭据→connected:false)。- store OAuth 读写(
services/credentials.py,C2 扩 K1.1 收口):StoredCredential加auth_type:str/oauth_enc:bytes|None,api_key_enc改bytes|None(K1.1 的 2 处 mypy red 已消)。新方法:upsert_oauth_credential(owner,provider,oauth_enc)(auth_type="oauth"+清 api_key_enc,read-modify-write 守可空 project_id 约束)、delete_credential(owner,provider)->bool。upsert_credential(api_key 路径)现显式 set auth_type="api_key"+清 oauth_enc。 _build_provider_adapterKimi Code OAuth 接线(services/project_deps.py):providerkimi-code或auth_type="oauth"→_resolve_kimi_code_token:解密oauth_enc→needs_refresh(剩余寿命 < 300s)则经临时httpx.AsyncClient调kimi_oauth.refresh+upsert_oauth_credential持久化新包 → 用(刷新后的)access token 调build_adapter("kimi-code", api_key=<token>, base_url=…)(K1.2 工厂构带伪造头 + coding base 的KimiCodeAdapter)。无oauth_enc→LLM_UNAVAILABLE。_PROVIDER_BASE_URLS加"kimi-code": "https://api.kimi.com/coding/v1"。- 服务缝(
services/kimi_oauth.py,注入AsyncHttpClientProtocol =httpx.AsyncClient.post子集,测试零联网):start_device_authorization(http)->DeviceAuth、poll_token(http, device_code)->TokenSet(一次尝试,AuthorizationPending/SlowDown异常供调用方循环)、refresh(http, refresh_token)->TokenSet;encrypt_oauth_bundle/decrypt_oauth_bundle(Fernet 复用security/credentials的encrypt_api_key/decrypt_api_key,工作在 JSON 串上);needs_refresh(token)。client_id envKIMI_CLIENT_ID可覆盖默认17e5f671-...;device authorization 带scope=kimi-code(coding entitlement;实测省略 scope→api.kimi.com/coding/v1回 401,故重新带上。token 交换/刷新不带 scope)。 - @frontend 行动(K1.4):
pnpm gen:api纳入 OAuth start/disconnect/status + 3 schema;连接流 = start 拿 user_code + 开 verification_uri → 轮询GET /jobs/{job_id}到 done(已连接)/failed(过期/拒绝);status 端点查连接态;档位路由到kimi-code:kimi-for-coding。
C3 扩展(T6.2/T6.3, 2026-06-22)· 创作工具箱通用端点(独立 routers/toolbox.py,已注册) owner @backend 状态: 稳定
- 通用生成器框架:声明驱动、一条执行路径——「加一个生成器」= 在
ww_skills.TOOLBOX加一份GeneratorTool声明(descriptor 在代码、无 DB 迁移),无需新端点。snake_case;新增 3 端点 + 新 schema → @frontend 必须cd apps/web && pnpm gen:api(T6 前端前置)。 GET /skills/toolbox→ 200ToolboxListResponse{tools:[ToolDescriptorView{key,title,subtitle,genre?,is_legacy:bool,ingestable:bool,input_fields:[ToolInputFieldView{name,label,type,required,default?,help?}],legacy_route?:str}]}(legacy 3 + 新 8,按 key 升序)。legacy 工具(worldbuilding/character/outline,is_legacy=true)携legacy_route(/projects/{id}/codex?gen=world·?gen=character·/projects/{id}/outline)供前端跳现有页面,不回归既测。POST /projects/{project_id}/skills/{tool_key}/generate←ToolGenerateRequest{brief:str="", chapter_no:int|None(ge1), count:int|None(1..12), kind:str|None}→ 200ToolGeneratePreviewResponse{tool_key, output_kind:str(产物 schema 名), preview:dict(各工具 output_schema 的 model_dump)}。预览不入库(按spec.tier经get_tier_gateway_builder建网关 →run_generator);末尾commit()仅落 ledger。context 派发(PUREservices/toolbox_context.build_toolbox_context):brief_only/with_project→build_brief_context、with_world→ + world_entities 硬规则卡、with_outline_chapter→ + 指定章 outline beats(缺章/缺大纲→空节拍不报错)。未知 tool_key→404;legacy 工具(spec=None)→404(前端走 legacy_route);项目不存在→404;无凭据→503LLM_UNAVAILABLE。POST /projects/{project_id}/skills/{tool_key}/ingest←ToolIngestRequest{world_entities:[WorldEntityCardView], chapter_no:int|None, scenes:[OutlineSceneIngestView{idx,beat,purpose,conflict,hook}], acknowledge_conflicts:bool=false}→ 201ToolIngestResponse{table:str, created:list[str], rejected_tables:list[str]}。仅ingest!=None的工具(golden-finger/glossary→world_entities,fine-outline→outline);其余→422 VALIDATION。入库 gate(仿POST /characters):world_entities 走 continuity 预检(analyst 网关) 比对待入库实体 vs 既有世界观真相源——有冲突且acknowledge_conflicts=false→409CONFLICT_UNRESOLVED+details{conflicts:[{type,where,refs,suggestion}],conflict_count};partition_writes(spec,{table:items})白名单过滤(越权→rejected_tables审计);schema→DB JSONB 形变写库(rules→{"rules":[...]};细纲场景按 idx 升序拼成该章 beats,outline_write_repo.upsert_chapter)。outline(细纲)不做 continuity 预检(场景是粗节拍展开,非设定/角色比对),无 chapter_no→422。未知/legacy→404。提交边界:ledger + 写侧只 flush → 端点末尾一次commit()(含 409 路径也 commit 落 precheck usage)。- 注册表(
packages/skills/ww_skills/toolbox_registry.py,经ww_skills导出):TOOLBOX:dict[str,GeneratorTool](11 条)+get_tool(key)->GeneratorTool|None。新工具的key==spec.name、output_schema is spec.output_schema。 - 注入缝(
services/project_deps.py):get_tier_gateway_builder→TierGatewayBuilder=Callable[[Tier],Awaitable[Gateway]](按工具 tier 动态建网关);测试经app.dependency_overrides[get_tier_gateway_builder]注返 mock 网关的 builder +get_world_entity_write_repo/get_outline_write_repo/get_memory_repos/get_project_repo注 fake(schema-routing fake 网关按req.output_schema返 IdeaListResult/ContinuityReview/…)。 - 守不变量:#2(spec 只声明 tier)/#3(预览不写库;入库经 continuity gate + 白名单)/#9(system_prompt 进缓存前块、注入材料进 input)。无新迁移(descriptor 在代码,复用 world_entities/outline 表)。
- @frontend 行动:
pnpm gen:api纳入GET /skills/toolbox+POST .../skills/{tool_key}/generate+POST .../skills/{tool_key}/ingest(新 schemaToolboxListResponse/ToolDescriptorView/ToolInputFieldView/ToolGenerateRequest/ToolGeneratePreviewResponse/ToolIngestRequest/ToolIngestResponse/OutlineSceneIngestView);工具箱落地页据GET /skills/toolbox渲染卡片栅格(legacy 走legacy_route跳页,新工具据input_fields声明驱动表单 → generate 预览 → 可入库者 ingest + 复用 409 冲突裁决)。
C8 · Skill registry + 表权限沙箱 owner @backend 状态: 稳定(T5.5, 2026-06-19;M5 R3 迁入 ww_skills 包, 2026-06-19)
- 来源:
ARCHITECTURE.md §5.6/PRODUCT_SPEC §5.5(声明式 Skill = AgentSpec;「声明式权限 + apply 层白名单」,非进程沙箱)。消费方:M5 编排/生成入库层、技能库 UI(T5.6,经 C3)。 - 实现位置(M5 R3 已迁移):实现现落
packages/skills/ww_skills/{skill_registry,skill_permissions}.py(packages/skills已是 uv workspace member)。ww_skills/__init__.py直接导出(不再是再导出 ww_core 的门面)。消费方from ww_skills import SkillRegistry/SqlSkillRepo/SkillRecord/SkillRepo/partition_writes/filter_reads/validate_declaration/KNOWN_TABLES。ww_core.domain已删除这两个模块 + 其__init__导出(不再经 ww_core.domain 暴露 skill 符号)。历史:T5.5 曾因 skills 非 workspace member 暂落 ww_core.domain(见 decisions),现已迁回。 - registry:
SkillRecord{name,scope,tier:Tier,system_prompt,reads:list[str],writes:list[str],genre?}(frozen,skills行的声明式快照;input/output JSON Schema 暂不携带 Python 类型——纯声明执行)。SkillRepo(Protocol).list_all()->list[SkillRecord];SqlSkillRepo(session)读skills表(裸tier非法 → VALIDATION)。SkillRegistry.load(repo)(classmethod,async)→ 每条跑validate_declaration(越权声明 → AppError VALIDATION,加载中断)→ frozendict[name,AgentSpec];get(name)->AgentSpec(缺 → NOT_FOUND)、names()->list[str]、list_scope(scope)->list[AgentSpec]。不变量 #2:只带tier,不解析 model(model 解析在网关)。 - 表权限沙箱(
skill_permissions.py,纯函数,不可变):KNOWN_TABLES(10 创作表白名单:projects/characters/world_entities/outline/chapter_digests/foreshadow/style_fingerprint/rules/chapters/chapter_reviews;系统/运营表不在列)。filter_reads(spec, available)->dict(注入只喂声明reads的表);partition_writes(spec, produced)->(allowed:dict, rejected:list[str])(写库只应用声明writes,越权表名进 rejected 供审计 log + 丢弃);validate_declaration(spec)(reads/writes 越KNOWN_TABLES→ VALIDATION)。不变量 #3:allowed仍须过验收 gate 才真入库,本层只裁剪白名单、不开写后门。 - 注入缝:
get_skill_registry(SkillRegistry.load(SqlSkillRepo(session)),async dep)。T5.2 生成入库层应用partition_writes落自定义 skill 产出(仅声明 writes 表,越权审计),仍经验收。 - @llm 注意:自定义 skill 经网关执行时复用 §5.1 机制(registry 给 spec,网关按 tier 路由);apply 层(编排/入库)须调
filter_reads/partition_writes守白名单。
C3.5 · jobs 长任务基建(写/进度/回收 + run_job) owner @backend 状态: 稳定(T4.1, 2026-06-18)
- 来源:ARCH §7.4(
jobs表 +GET /jobs/:id轮询;BackgroundTasks 无专用队列)。jobs表 +Job模型 +GET /jobs/{id}读端点已 T0.3 建齐;T4.1 补写/进度/回收层,供 T4.3「学文风走 jobs」 复用。 - 位置:
packages/core/ww_core/domain/job_repo.py(经ww_core.domain导出JobRepo/JobView/SqlJobRepo)+apps/api/ww_api/services/job_runner.py(run_job)+services/project_deps.get_job_repo+ lifespan reaper。 JobView(frozen Pydantic, snake_case):id:uuid、kind:str、status:str、progress:int=0、result:dict|None=None、error:str|None=None(镜像GET /jobs/{id}出参)。JobRepoProtocol(写方法只 flush 不 commit,提交归run_job/端点;唯reap_zombies自 commit):async create(project_id: uuid|None, kind: str) -> JobView(status=queued, progress=0)async set_running(job_id) -> JobView(status=running)async set_progress(job_id, pct: int) -> JobView(pct 夹取到 0..100)async complete(job_id, result: dict) -> JobView(status=done, progress=100, result=)async fail(job_id, error: str) -> JobView(status=failed, error=)async get(job_id) -> JobView | Noneasync reap_zombies() -> int(bulk UPDATErunning→failed(+error 文案),返回被改条数,自 commit)- 状态写方法对不存在的 job 抛
LookupError。状态常量导出:STATUS_QUEUED/RUNNING/DONE/FAILED、PROGRESS_COMPLETE=100(job_repo模块级)。
run_job缝(services/job_runner.py):async def run_job(session_factory: SessionFactory, job_id: uuid, work: JobWork, *, request_id: str|None=None, repo_factory=_default_repo_factory) -> None,其中JobWork = Callable[[AsyncSession], Awaitable[dict]]。语义:自建独立 session(session_factory,仿run_overdue_scan,请求 session 已关闭)→set_running→await work(session)(业务写与 job 状态写同一 session)→complete(job_id, work 返回值)→commit;异常路在全新 session 里fail(job_id, str(exc))+commit,异常被吞不冒泡(后台任务边界)。SessionFactory复用services/foreshadow_scan.SessionFactory(=get_session_factory/get_sessionmaker())。repo_factory: Callable[[AsyncSession], JobLifecycleRepo](最小 Protocol:仅set_running/complete/fail)是可注入缝,单测注 fake。- lifespan reaper(M4-d):
main.py_lifespan在seed_stub_user之后SqlJobRepo(session).reap_zombies()(幂等、自 commit)——进程重启丢 BackgroundTask 的残留running行标failed,用户可见可重试。 - T4.3 行动:
POST /style→job_repo.create(project_id,"style_learn")+ commit 返 202{job_id};background_tasks.add_task(run_job, session_factory, job.id, work=<部分应用 run_style_extraction→StyleFingerprintWriteRepo.append 并返回 {version,dims_count} 摘要>)。work内自建 gateway(analyst)+ repo(用run_job传入的独立 session)。注入缝get_job_repo/get_session_factory测试经app.dependency_overridesoverride。
C4 · 编排器接口(LangGraph 写章图) owner @llm 状态: 稳定(M1/T1.3, 2026-06-18;M2 扩四审/验收)
- 来源:
ARCHITECTURE.md §5.2(图)/ §7.3(SSE)。M1 仅单write节点;并行四审/collect/interrupt(accept) 属 M2。 - 位置:
packages/core/ww_core/orchestrator/。 - 图状态
ChapterState(TypedDict, snake_case):{project_id:UUID, chapter_no:int, user_id:UUID, stable_core:str, volatile:str, draft:str}——仅控制流+组装上下文+累积草稿(不变量#5);resume 从领域表重读。 - 节点缝:
build_write_request(*, stable_core, volatile, user_id, project_id) -> LlmRequest(纯函数;tier="writer",system=[Block(stable_core,cache=True)],input=volatile,stream=True,scope=Scope(user_id,project_id))。async stream_chapter_draft(gateway, *, stable_core, volatile, user_id, project_id) -> AsyncIterator[Delta]——T1.4 拿来喂normalize_deltas的底层流缝。async write_node(state, *, gateway) -> {"draft":str}——可直接单测(注入 mock 网关)。GatewayStreamProtocol = 节点对网关最小依赖(只需.stream(req))。
- SSE 归一缝(T1.4 消费):
async normalize_deltas(deltas, *, request_id=None) -> AsyncIterator[SseEvent];SseEvent{event:str, data:dict};事件token{text}/done{length}/error{code,message,request_id}(ARCH §7.3 子集,M2 加 section/conflict)。底层异常→发error事件后收尾(不上抛);AppError.code透传,未知→INTERNAL。HTTP event-stream 编码归 T1.4。 - 图工厂:
build_write_graph(gateway, *, checkpointer=None) -> CompiledStateGraph(START→write→END);单测传MemorySaver。 - checkpointer setup 入口:
async setup_checkpointer(conn_string) -> None——只在 migrations/CI 调(内部AsyncPostgresSaver.from_conn_string跑一次setup()DDL;懒 import,绝不在 app-runtime 跑)。 - 不变量:agent 只传 tier;DB 唯一 agent 间通道;不可变更新;瞬时重试在网关不在节点。
C4 扩展(T2.2, 2026-06-18)· 审稿子图 + review SSE
- 图工厂
build_review_graph(gateway, review_repo, *, review_specs=(continuity_spec,), checkpointer=None) -> CompiledStateGraph:START →各 review spec 并行节点→ collect → END。M2 默认仅 continuity,review_specs可扩(M3/M4 加 foreshadow/style/pace)。build_write_graph保留不动(draft 端点仍用它)。 ChapterState(state.py) 新增review_context:str、reviews:Annotated[dict, merge_reviews](并行分支浅合并 reducer,返回新 dict);TypedDict 改total=False(按节点逐步填充);仍仅控制流+组装上下文+产物句柄(不变量#5)。- 节点缝:
run_review(spec, state, *, gateway)->{"reviews":{spec.name:{status,result}}}(裸函数可单测);make_review_node(spec, gateway)(绑 gateway 的公共缝,供 T2.5 跑单审);审稿用gateway.run()(非 stream),只读不写库(不变量#3);任一审网关失败被隔离为{status:"incomplete",result:None}(§5.2),不上抛、不毁图。GatewayRunProtocol=节点对网关最小依赖(.run(req)->LlmResponse)。 - collect 缝:
collect_reviews(state, *, review_repo)->{}:抽 continuity 冲突 →review_repo.record(project_id, chapter_no, chapter_version=None, conflicts=[...])落chapter_reviews留痕;只 flush 不 commit(提交归端点/T2.4 事务)。ReviewRecorderProtocol 形对齐domain.review_repo.ReviewRepo.record。 - SSE 新增事件:
section{name,status}(status ∈ started/done/incomplete)、conflict{type,where,refs,suggestion}(对齐 C6Conflict);新归一缝normalize_review(reviews, *, request_id=None)->AsyncIterator[SseEvent](每审一条 section + 每冲突一条 conflict +done{length=审项数};异常→error不上抛,同normalize_deltas纪律)。HTTP event-stream 编码归 T2.5。 - 一键采纳改法(方案1):
conflictSSE 事件 +ReviewConflictView(留痕)+ C6Conflictschema 新增可选original?:str|null/replacement?:str|null(一键采纳补丁对,最小句段;审稿可局部修复时提议,前端「采纳改法」把 original→replacement find-replace 进终稿,找不到则不动正文提示手改)。向后兼容:缺省 null;审稿仍只读(只提议不改稿,不变量#3)。 - accept 不在本图:
interrupt_before=["accept"]/accept 节点属 T2.4(确定性事务代码,从领域表重读,R3/不变量#5)。 - 消费方行动:T2.5 跑 review 子图 +
normalize_review+ 端点流耗尽后await session.commit()(网关 ledger + collect 均只 flush,不提交则记账/留痕静默丢失,同 M1 draft 坑);GET .../reviews用review_repo.list_for_chapter(新→旧)。T2.4 从review_repo.list_for_chapter读 collect 落的行(decisions=None)裁决。
C4 扩展(T3.3, 2026-06-18)· 三审齐(foreshadow + pace 并入)
REVIEW_SPECS = (continuity_spec, foreshadow_spec, pace_spec);build_review_graph(..., review_specs=REVIEW_SPECS)默认三审齐(文风第四审留 M4)。run_review/make_review_node对 spec 泛型(按req.output_schema产 parsed)。- collect 列映射(一次
review_repo.record(...)落齐):continuity→conflicts(list);foreshadow→foreshadow_sug(list,把planted/resolved扁平成单 list、每条加kind:"planted"|"resolved");pace→pace(dict,整{water,hook,beat_map}入列);style 留 M4。任一审 incomplete→该列空/None,不阻塞其余(§5.2)。 normalize_reviewSSE(向后兼容,section/conflict不变)新增:foreshadow{kind,code?,title,where?,note?}(每条建议一条)、pace{water:[{where,reason}],hook:bool,beat_map:[int]}(一条)。键按字典序确定性遍历(continuity<foreshadow<pace)。incomplete 审仅发section不发结果。- 消费方:前端 T3.6(冲突就地标注 / 伏笔看板联动 / 节拍图 ▁▃▅)、E2E T3.7。
C4 扩展(T4.2, 2026-06-19)· 四审齐(style 漂移并入)+ style SSE
REVIEW_SPECS = (continuity_spec, foreshadow_spec, pace_spec, style_drift_spec);build_review_graph(...)默认四审齐。图工厂/run_review/make_review_node对 spec 泛型,无改动(按req.output_schema产 parsed,style 天然套循环)。- collect 列映射新增:
extract_style(reviews)->dict|None(取 style 审 ok 结果整 dict);collect_reviews末尾style=extract_style(reviews)传入review_repo.record(...)(chapter_reviews.styleJSONB 列已存在)。无指纹降级(score=100/空段)仍是ok结果、照常落库(区别于 incomplete→None)。 normalize_reviewSSE(向后兼容)新增:style{score:int, segments:[{idx:int,score:int,label:str|None}]}(一条)。键按字典序遍历 → continuity<foreshadow<pace<style,确定性顺序。incomplete 审仅发section{status:incomplete}不发结果。- 常量/工厂导出(经 orchestrator
__init__):STYLE="style"、extract_style(collect);EVENT_STYLE="style"、style_event(*, score, segments)(sse)。 - 消费方:前端 T4.4(StylePanel ◔ 相似度 + 漂移段一键回炉,
section{name:"style"}/style{...}reduce)、E2E T4.5(断言事件真发 +chapter_reviews.style真填)。
C6 扩展(T4.2, 2026-06-19)· style-auditor 双轨 + refiner spec/schema
- 位置:
packages/agents/ww_agents/{schemas.py,specs.py}(经ww_agents导出);提取节点在packages/core/ww_core/orchestrator/style_extract_node.py(经 orchestrator 导出)。 - 提取轨 schema:
StyleDimension{name:str, value:str, evidence:list[str]=[]}、StyleFingerprintResult{dimensions:list[StyleDimension]=[]}(16 维 = 9 通用 + 7 中文网文,每维带原文证据)。 - 漂移轨 schema:
StyleDriftSegment{idx:int, score:int, label:str|None=None}、StyleDriftReview{score:int=100, segments:list[StyleDriftSegment]=[]}(默认值即无指纹降级态:score=100/空段)。 style_extract_spec(frozenAgentSpec):name="style_extract",tier="analyst",output_schema=StyleFingerprintResult,reads=["style_fingerprint"],writes=["style_fingerprint"](声明式,真写库经 T4.3)。独立生成(仿 outliner),不进 review 图。style_drift_spec:name="style"(=section/列名),tier="light",output_schema=StyleDriftReview,reads=["style_fingerprint"],writes=[](只读,第四审)。system_prompt 含「材料无指纹→返回 score=100/空段」降级指令。refiner_spec:name="refiner",tier="writer",output_schema=None(纯文本重写段),reads=[],writes=[](非持久;端点同步返回{original,refined}不写库,作者采纳经既有 draft 自动保存合入,不变量 #3)。- 提取节点缝(模块内自有
GatewayRunProtocol,不跨模块复用、不在__init__重复导出,见 gotcha):build_style_extract_request(spec, *, samples_text, user_id, project_id)->LlmRequest(output_schema=StyleFingerprintResult,stream=False,system 缓存断点前块) +async run_style_extraction(spec, *, samples_text, gateway, user_id, project_id)->StyleFingerprintResult(gateway.run().parsed;只读不写库;网关失败直接上抛不隔离;parsed 违约抛ValueError,仿 run_outline)。 - 消费方:T4.3 端点
POST /style经 BackgroundTask 调run_style_extraction拿StyleFingerprintResult→ 拆dimensions_json/evidence_json写style_fingerprint表;POST .../refine调refiner_spec(writer 网关)run()重写段返回{original,refined}。@frontend 行动:T4.4 前pnpm gen:api纳入 style/refine/jobs 端点。
C6 扩展(T5.1, 2026-06-19)· worldbuilder + character-gen spec/schema + 入库前 continuity 校验缝
- 状态:稳定。位置:
packages/agents/ww_agents/{schemas.py,specs.py}(经ww_agents导出);生成节点在packages/core/ww_core/orchestrator/generation_node.py(经 orchestrator 导出)。T5.2 入库端点据此构建(field 名已对齐world_entities/charactersDB 列,T5.2 ingest 直接映射)。 - worldbuilder schema:
WorldEntityCard{type:str, name:str, rules:list[str]=[]}(rules = 显式硬规则清单,供 continuity 引用)、WorldGenResult{entities:list[WorldEntityCard]=[]}。映射world_entities表:type/name 列 + rules 入 JSONB(DB 列rules是 JSONB;入库可包成{"rules":[...]}或裸 list,由 T5.2 定夺)。 - character-gen schema:
CharacterRelation{name:str, kind:str, note:str|None=None}、CharacterCard{name:str, role:str, traits:list[str]=[], backstory:str, arc:str, speech_tics:list[str]=[], tags:list[str]=[], relations:list[CharacterRelation]=[]}(必填:name/role/backstory/arc;其余集合默认空)、CharacterGenResult{cards:list[CharacterCard]=[]}。映射characters表:name/role/backstory 列 + traits/arc/speech_tics/tags/relations 入各自 JSONB(DBtraits/arc/speech_tics是 JSONB dict 列、tags/relations是 JSONB list——T5.2 ingest 须把list[str]的 traits/speech_tics 包进 dict 或调整,按 DB 列形落库;role= 角色定位 主角/CP/对手/导师/工具人)。 worldbuilder_spec(frozenAgentSpec):name="worldbuilder",tier="writer",output_schema=WorldGenResult,reads=["projects"],writes=["world_entities"](声明式,真写库经 T5.2)。独立生成(仿 outliner),不进 review 图。system_prompt 强调硬规则显式可校验。character_gen_spec:name="character-gen",tier="writer",output_schema=CharacterGenResult,reads=["world_entities","characters"],writes=["characters"]。system_prompt 注入「已生成卡 + 已有角色」差异化要求(M5-a 防雷同)——含「已有角色」「已生成卡」「防雷同」字样。- 生成节点缝(模块内自有
GatewayRunProtocol,不跨模块复用、不在__init__重复导出,见 gotcha;导出公共函数 + 三个 context builder):async run_worldbuilder(spec, *, brief, project_context, gateway, user_id, project_id)->WorldGenResult(+build_worldbuilder_context(*, brief, project_context)->str)。async run_character_gen(spec, *, brief, count:int, role:str|None, world_context:str, existing_chars:Sequence[CharacterCard], generated_so_far:Sequence[CharacterCard], gateway, user_id, project_id)->CharacterGenResult(+build_character_gen_context(*, brief, count, role, world_context, existing_chars, generated_so_far)->str——防雷同上下文:注入已有+已生成卡的name(role):traits简表,要求差异化;空时给「(暂无…)」占位不崩)。- 入库前 continuity 校验缝(ARCH §6.5,T5.2 调):
async precheck_generated_cards(spec, *, cards:Sequence[CharacterCard], world_context:str, characters_context:str, gateway, user_id, project_id)->list[Conflict](+build_precheck_context(*, cards, world_context, characters_context)->str)。复用continuity_spec(analyst 档,只读)跑生成卡 vs 世界观/已有角色真相源比对、返回list[Conflict](C6Conflict五类)。编排器追加的检查,非 character-gen 直接互调(守 §5.4 数据流/不变量 #1)。T5.2 入库端点用返回的 conflicts 做 gate(有冲突→提示作者裁决/调整,不静默入库)。 - 三函数均:生成用
run()非stream();output_schema=spec.output_schema;system 缓存断点前块(cache=True,#9);网关失败直接上抛(端点处理,独立生成不做失败隔离);parsed 违约抛ValueError(仿 run_outline/run_style_extraction);只读不写库(#3)。
- 测试替身:复用
packages/core/tests/fakes_orchestrator.py的FakeRunGateway/SchemaRoutingRunGateway/FailingRunGateway(按req.output_schema路由 parsed)。 - 消费方:T5.2 端点
POST /world/generate调run_worldbuilder、POST /characters/generate调run_character_gen(批量循环时把已产卡传generated_so_far防雷同)、POST /characters(入库) 前调precheck_generated_cardsgate;analyst 网关复用build_gateway_for_tier(session,store,"analyst"),writer 用"writer"。记账闭环同既有(端点末尾 commit)。
C5 · 记忆服务 assemble / select_relevant_entities owner @backend 状态: 稳定(T1.2, 2026-06-18)
- 来源:
ARCHITECTURE.md §3.4 / §5.3(确定性选择:显式+主角+近况;渲染卡片;缓存断点)。 - 位置:
packages/core/ww_core/memory/(+domain/repositories.py)。 - 输出(决策:中性文本,非
LlmRequest,见 decisions 2026-06-18):AssembledContext{ stable_core:str, volatile:str, selection:SelectionTrace }stable_core:断点前(作品蓝本(title/logline/premise/theme)+世界硬规则+定型主角(无 latest_state)+文风指纹+合并规则),已排序/无时间戳/无 UUID。「作品蓝本」是书级 spec、定型 → 入缓存前缀首段。volatile:断点后(写作指令「请创作第 {chapter_no} 章的正文。」始终在场+注入卡片(含 latest_state)+伏笔窗口+近况摘要+本章 beats)。
- 空-prompt 保证(2026-06-19 修,bugfix):哪怕世界观/角色/大纲全空,只要项目有 premise/title,
stable_core(含作品蓝本) 与volatile(含写作指令) 均非空 → writer 的 user message 永不为空(修 LLM400 "message at position 0 ... must not be empty")。 SelectionTrace{ selected:list[SelectedEntity] };SelectedEntity{ kind:"character"|"world_entity", name:str, reasons:list[SelectionReason] };SelectionReason="explicit_beat"|"main_character"|"recent_digest"|"foreshadow_window"。
- 入口:
async assemble(repos:MemoryRepos, project_id, chapter_no, recent_k=5)->AssembledContext;纯函数select_relevant_entities(*, outline, characters, world_entities, recent_digests)->SelectionTrace;render_cards(selection, characters, world_entities)->str;merge_rules(rules)->list[RuleView](global→genre→style→project)。 - 依赖注入:
MemoryRepos捆绑 8 个 Protocol(Outline/Character/WorldEntity/Digest/Foreshadow/Style/Rules/Project);单测注入内存 fake,运行时sql_memory_repos(AsyncSession)(T1.4 注入点)。ProjectSpecRepo.spec(project_id)->ProjectSpecView|None(ProjectSpecView{title, logline?, premise?, theme?},按 project_id 读projects行;2026-06-19 新增,消费方须在MemoryRepos(...)补project=字段)。 - 不变量:确定性选择(无向量 #6);统一 project_id 过滤(§3.5);latest_state 严格归 volatile(#9)。
- 消费方:T1.3 write 节点 → 构造
LlmRequest(system=[Block(stable_core, cache=True)], input=volatile)。
C6 · Agent 声明(AgentSpec)+ 续审 I/O schema owner @llm 状态: 稳定(T2.1, 2026-06-18)
- 来源:
ARCHITECTURE.md §5.1 / §5.4 / §6.1。位置:packages/agents/ww_agents/(specs.py/schemas.py,经ww_agents导出)。 AgentSpec(frozen Pydantic,不可变):name:str、tier:Tier(writer/analyst/light,复用ww_llm_gateway.types.Tier——只声明档位不写 model,不变量#2)、system_prompt:str、input_schema:type[BaseModel]|None、output_schema:type[BaseModel]|None(writer 为 None=纯文本)、reads:list[str]、writes:list[str]、genre:str|None=None、scope:str="builtin"。continuity_spec:tier="analyst"、reads=["chapter_digests","characters","world_entities"]、writes=[](只读,不变量#3)、input_schema=None(注入材料为序列化文本)、output_schema=ContinuityReview。ContinuityReview{ conflicts: list[Conflict] }(仅冲突;digest 不在审稿期产,不变量#4)。Conflict{ type: ConflictType, where:str, refs:list[str]=[], suggestion:str };ConflictType = Literal["性格漂移","能力不符","设定违例","地理矛盾","时间线倒错"](ARCH §6.1 五类)。- 续审节点调法:
req = LlmRequest(tier="analyst", system=[Block(system_prompt, cache=True)], input=审稿上下文文本, output_schema=ContinuityReview, scope=...)→resp = await gateway.run(req)→resp.parsed为ContinuityReview实例(带 schema 时必非 None)。续审用run()非stream();只读+产冲突,不写库(写在 T2.4 验收事务)。 - 消费方:编排器(T2.2 续审/collect 节点)、技能运行时(M5)、前端审稿页(经 C3)。foreshadow/style/pace 三审 spec 待 M3-T3.3/M4 补入本契约。
C6 扩展(T3.4, 2026-06-18)· outliner Agent + 大纲/伏笔窗口 schema
- 位置:
packages/agents/ww_agents/{schemas.py,specs.py}(经ww_agents导出);节点在packages/core/ww_core/orchestrator/outline_node.py(经 orchestrator 导出)。 outliner_spec(frozenAgentSpec):name="outliner"、tier="analyst"、reads=["projects","foreshadow","characters","world_entities"]、writes=["outline"](声明式,真写库经 T3.5,不变量#3)、input_schema=None、output_schema=OutlineResult。OutlineResult{chapters:list[OutlineChapter]};OutlineChapter{no:int, beats:list[str], foreshadow_windows:list[ForeshadowWindow]};ForeshadowWindow{code:str, plant_chapter?:int, expected_close_from?:int, expected_close_to?:int}(§6.2 关联章与伏笔)。- 节点缝(沿用 review_node 的
GatewayRunProtocol.run(req)->LlmResponse):build_outline_request(spec, *, context, user_id, project_id)->LlmRequest(output_schema=OutlineResult,stream=False) +async run_outline(spec, *, context, gateway, user_id, project_id)->OutlineResult(gateway.run().parsed;只读不写库;大纲独立生成、非并行审,网关失败直接上抛不隔离,parsed 违约抛ValueError)。 - 消费方:T3.5 端点调
run_outline拿OutlineResult→ 逐章 upsertoutline表(beats/foreshadow_windows入 JSONB,volume由端点分卷逻辑提供,schema 不含 volume);analyst 网关复用build_gateway_for_tier(session,store,"analyst")/get_review_gateway;记账闭环同 M2(端点末尾commit())。T3.6 前端大纲编辑器消费窗口。
C6 扩展(T3.3, 2026-06-18)· foreshadow-analyst + pace-checker spec
foreshadow_spec(name="foreshadow", tier="analyst", reads=["foreshadow"], writes=[]只读, output=ForeshadowReview{planted:[ForeshadowSuggestion],resolved:[ForeshadowSuggestion]};ForeshadowSuggestion{code?,title,where?,note?})。pace_spec(name="pace", tier="light", reads=["rules"], writes=[]只读, output=PaceReview{water:[PaceIssue{where,reason}],hook:bool,beat_map:[int]})。genre 模板 DSL = pace system_prompt 内描述黄金三章/章末钩子/爽点密度,genre 级模板经 review_context 规则合并(rules)注入,未新建表/repo。- 三审只读,留痕经 collect→review_repo(写库 commit 归端点)。详细图/SSE 见上「C4 扩展(T3.3)」。
C7 · 前端 ↔ 后端类型契约 owner @backend(产出 OpenAPI) / @frontend(生成) 状态: 待定义
- 机制:FastAPI OpenAPI →
apps/web/lib/apiTS 类型(openapi-typescript/orval)。 - 规则:后端 schema 任何变更 → 跑
gen:api重生成;不手写共享类型。
C-Chain · 多章工作流链端点 + 服务层 owner @backend / @llm(图) 状态: 稳定(C2, 2026-06-23)
设计真源:
docs/design/chain-workflow.md。C1(@llm) 图签名 §3.3;C2(@backend) 端点/schema/服务接线。
- 3 端点(
routers/chain.py,挂/projects前缀):POST /projects/{pid}/chains/{chain_key}/run←ChainRunRequest{start_chapter_no:int>=1, count:int 1..50}→ 202ChainRunAccepted{job_id, chain_key, start_chapter_no, count}。写一行jobs(queued, kind="chain")返 202,链经 BackgroundTaskrun_chain_job跑(自建独立 session)。未知 chain_key→404(仅draft_volume);count 越界→422(schema Field);项目不存在→404;无凭据→503。GET /jobs/{job_id}(复用现有):轮询进度/awaiting。链 jobresult = {chain_key, written:[int], completed:bool, awaiting_chapter:int|null};interrupt 命中时status="awaiting_input"+result.awaiting_chapter。POST /projects/{pid}/chains/runs/{job_id}/resume←ChainResumeRequest{decisions:[ConflictDecision]}→ 202ChainRunAccepted。仅当 job=awaiting_input;非该态→409CONFLICT;job 不存在→404。带裁决Command(resume=...)续跑。
- schema(
schemas/chain.py):ChainRunRequest/ChainRunAccepted/ChainResumeRequest;ConflictDecision复用schemas/projects.py(conflict_index/verdict/note)。 - 零迁移(§7):复用
jobs(kind/status/progress/result 全已存);新增status值"awaiting_input"(jobs.status 自由 Text 列,无 DDL);awaiting 章经result.awaiting_chapter表达。JobRepo加set_awaiting(job_id, result)。唯一新增 DDL = langgraph 检查点表(C3 迁移建,本任务未引入)。 - 新增错误码(
ww_shared/errors.py):ErrorCode.CONFLICT→409(资源状态冲突,如对非 awaiting 链 job 续跑;与CONFLICT_UNRESOLVED区分)。 - 新网关缝(
project_deps.py):get_chain_gateway(build_chain_gateway:按请求 tier writer/analyst/light 分派、union 三档适配器+回退链——单档网关 chain_resolver 恒返该档会错路由 review/digest,故链需多档分派);get_digest_gateway_builder(accept 节点自建短事务建 light 档 digest 网关)。get_checkpointer_factory(services/chain_deps.py:运行时AsyncPostgresSaver.from_conn_string(database_url_sync)上下文;测试注 MemorySaver 工厂)。 - token 纪律:job result/status/日志只记章号/计数/标志,绝不含 prompt/正文/token(§5,已断言)。
- → 影响 @frontend(链发起页/进度/裁决续跑面板,非目标本期不做,契约稳定后 follow-up
pnpm gen:api);@db/@devops C3(检查点建表迁移 + 可选 jobs 注释)。
C6-ext · Prompt 外置 + SpecResolver owner @llm(SPECS/loader/catalog) + @backend(resolver/守卫) 状态: 稳定(方案A,2026-06-24)
设计真源:
docs/design/prompt-management.md。纯重构、零功能/schema 变更、零迁移(前端无需pnpm gen:api)。
load_prompt(name) -> str(packages/agents/ww_agents/prompt_loader.py,@llm):按prompts/<spec.name>.mdimport 期读盘 → 去 BOM(utf-8-sig)/CRLF·CR→LF/NFC/rstrip('\n')→ 内存缓存 → 返回完整 UTF-8 文本;缺文件PromptNotFoundError(fail-fast,不 fallback、不插值)。SPECS: Final[dict[name, AgentSpec]](specs.py,@llm):21 内置 spec 集中名册,assert len(SPECS)==21;system_prompt=load_prompt(name)、output_schema=SCHEMA_CATALOG[name]。不变量:SPECS[name] is *_spec(兼容期同一实例,旧from ww_agents import *_spec不破);prompts/<name>.md/SPECS[name]/SCHEMA_CATALOG[name]三集合按 name 恒等。SCHEMA_CATALOG: Final[dict[name, type[BaseModel]|None]](schema_catalog.py,@llm):name→output type 唯一真相源(refiner=None);output_schema_for(name)。input 全 None,本波不建 input 槽(YAGNI)。REVIEW_RESERVED_NAMES: Final[frozenset]={continuity,foreshadow,style,pace}(@llm):四审受信名显式白名单(非派生集合),安全边界锚此。SpecResolver(packages/skills/ww_skills/spec_resolver.py,@backend):build(skills)纯合并dict(SPECS)+SkillRegistry(读路径无冲突校验);get(name)先查内置SPECS(纯内存、零 DB)未命中才查 registry,都无→NOT_FOUND;output_schema_for/names/list_scope。name 精确字符串相等(无大小写/连字符归一)。- 守卫前移:用户 skill 同名内置(
REVIEW_RESERVED_NAMES ∪ set(SPECS))在SkillRegistry入库/加载校验期即拒(AppError(VALIDATION),与validate_declaration同处),不在 resolver 读路径——内置get确定性、零运行时分叉,守不变量 #3。 - 打包契约(@devops):
.gitattributes锁packages/agents/ww_agents/prompts/*.md text eol=lf;packages/agents/pyproject.tomlhatchlingartifacts纳入prompts/*.md随 wheel/sdist 分发;CIagents-wheel-smoke(build→裸装→import ww_agents; assert ww_agents.SPECS)防.md漏带(源码树 pytest 测不出)。packages/{llm_gateway,core}补声明structlog(原直接 import 未声明,靠 apps/api 传递)。 - 消费方:
toolbox_registry.GeneratorTool.spec走SPECS["<name>"](不再直接 import 生成器*_spec)。本波不改编排器(REVIEW_SPECS/节点/§6.5 precheck/chain)与 apps/api 3 路由(toolbox/outline/style)——兼容期续用*_spec(因同一实例不变量无回归),切 resolver 列后续波次。
契约变更日志(append-only)
格式:
- [date] @skill 改 Cx:<改了什么> → 影响 <依赖方/任务>
-
[2026-07-06] @llm/@db/@backend/@frontend 改 C3+C4(③ 人物塑造 advisory 审):新增第五审 人物塑造(advisory 单维——仅建议、不阻断验收:不进 conflicts、不碰
assert_conflicts_resolved、不入REVIEW_RESERVED_NAMES,D2)。DB:chapter_reviews加 1 列characterization(JSONB nullable,迁移f6a7b8c9d0e1,独立于恒 None 的 health_score,nullable 免 backfill)。契约变更:ReviewHistoryItem(schemas/projects.py)+ReviewView(domain/review_repo.py,末位默认 None 优雅降级)各加characterization: dict|None;list_reviews路由透传。新 SPECcharacterization_spec(tier="analyst"不写 model,reads=("characters",)以人物卡 motive/appearance 为客观锚点,writes=()只读,prompts/characterization.md显式忽略文本长度与辞藻华丽度、引用逐字片段+置信度)+ schemaCharacterizationReview{issues:[CharacterizationIssue{character,aspect,where,quote,diagnosis,suggestion,confidence}]}(全字段默认值守解析韧性)+ 注册SCHEMA_CATALOG["characterization"];计数三处 +1(22→23):specs.pyassert、test_prompt_loader.pylen 断言、schema_catalog.pydocstring;regenprompt_hashes.json(仅加 characterization 一条)。接线链:graph.py REVIEW_SPECS末位追加(并行扇出自动汇入 collect);chain/nodes.py ReviewRecordRepo.record+collect.py {CHARACTERIZATION 常量, extract_characterization, ReviewRecorder Protocol, collect_reviews}+review_repo.py {Protocol.record, SqlReviewRepo.record, _to_view}均加characterizationkwarg/字段;sse.py {EVENT_CHARACTERIZATION, characterization_event, _section_result_events elif}(normalize 按 sorted 键自动纳入——字典序 characterization 排最前,不打乱既有四审断言)。前端lib/review/sse.ts(CharacterizationIssue/CharacterizationReport/CharacterizationEvent+ KNOWN_EVENTS +asCharacterizationIssues守卫 + reducer + state)+lib/review/history.ts normalizeCharacterization+useReviewStream ReviewSeed+ 新CharacterizationPanel.tsx(advisory 只展示、无裁决 UI、按置信度降序 + 低置信度折叠控注意力预算)+ReviewReport.tsx挂面板 + 双 seed 点。codegen:characterization= None→ openapi-typescript 渲染为可选。测试:test_characterization_does_not_block_accept(零 continuity 冲突 + 有 advisory 问题 → 验收直通)+ 计数/golden + collect 映射 + normalize 事件 + 前端 reducer/normalize/parse 纯函数。→ 影响 @frontend(已pnpm gen:api)。依赖 ⑧(motive/appearance 已入 CharacterCard 契约,作客观锚点)。 -
[2026-07-06] @llm/@backend/@frontend 立 C3 新端点(⑤ AI 立项方案生成):
POST /skills/project-plan/generate(不带 project 前缀——立项前 project 未建,绕开 toolbox 的project_repo.get→404)←ProjectPlanGenerateRequest{genre?,logline?,title?,premise?,theme?,structure?,tone?,ending_type?,narrative_pov?,selling_points[],brief?}(全字段max_length上界 CR-H9;brief用str|None令 codegen 渲染为可选)→ProjectPlanView{title_candidates[str], setting, narrative_structure, story_core, ending_design, tone}(全字段默认值守解析韧性)。种子门控:缺 genre 或 logline(strip 后空)→ 422 VALIDATION(空向导不出方案,防 slop);无凭据→503。新 SPECproject_plan_spec(tier="analyst"不写 model,reads=()/writes=()纯预览,prompts/project-plan.md)+ schemaProjectPlanResult(ww_agents.schemas)+ 注册SCHEMA_CATALOG["project-plan"];计数三处 +1(21→22):specs.pyassert、test_prompt_loader.pylen 断言、schema_catalog.pydocstring;regenprompt_hashes.json(仅加 project-plan 一条)。端点复用build_brief_context+run_generator(run_generator/_build_request的project_id放宽为uuid.UUID|None——向导阶段无 project,usage_ledger.project_id本就 nullable);新网关缝get_project_plan_gateway(analyst 档,project_deps.py)。零迁移(纯预览不写业务表,仅落 usage ledger)。前端wizard.ts加mapPlanResultToWizardForm(书名候选→title、叙事结构→structure、基调→tone;时空背景/结局设计无独立向导字段→折进 premise 带标签不静默丢;结局取向/叙事视角预设枚举不臆造)+applyPlanPatch(仅回填空字段、保护已编辑)+canGeneratePlan/toPlanSeedRequest+useProjectPlanhook(renderHook 测试)+ProjectWizard.tsx第 2 步PlanAssistant(生成→预览→按字段回填)。→ 影响 @frontend(已pnpm gen:api)。依赖 ④(tone/ending_type/narrative_pov 已入 project 契约与向导)。 -
[2026-07-06] @db/@backend/@frontend 改 C2+C3(④ 立项基调/结局/视角):
projects表加 3 列tone/ending_type/narrative_pov(Text nullable、无 CHECK,同 genre/structure;迁移e5f6a7b8c9d0,nullable 免 backfill)。ProjectCreateRequest+ProjectResponse(schemas/projects.py)各加 3 字段str|None(无 Literal,Literal 只留 Verdict);domainProjectCreate/ProjectView/_to_view/SqlProjectRepo.create+FakeProjectRepo同步透传。完整 stable_core 链路:ProjectSpecView(repositories.py,frozen)加 3 字段 +SqlProjectSpecRepo.spec查询构造带上 +_build_spec_section(assemble.py)渲染进「作品蓝本」块(书级常量→缓存前缀,字节稳定守 #9,绝不入 volatile)。前端wizard.tsWizardForm/emptyWizardForm/toCreateRequest+ 预设数组TONES/ENDING_TYPES/NARRATIVE_POVS(仿 GENRES/STRUCTURES)+ProjectWizard.tsx第 3 步控件(tone→Select,pov/ending→SegmentedControl)+ 确认页回显。codegen:3 字段皆= None无默认工厂 → openapi-typescript 渲染为?: string | null可选。→ 影响 @frontend(已pnpm gen:api);未来 ⑤ AI 立项方案回填这 3 字段。 -
[2026-07-06] @llm/@backend 改 C3(⑧ appearance/motive 穿线):
CharacterCardView(schemas/generation.py)+ww_agents.CharacterCard各加appearance:str=""+motive:str=""(均给默认值守解析韧性,DB 列characters.appearance/motive早已在初始迁移、此前一直 NULL——无新迁移)。写侧CharacterWriteRepo.create/SqlCharacterWriteRepo(create+update backfill 分支)/CharacterWriteFields同步加两列;routers/generation.py_card_to_view/_view_to_card/_existing_characters双向形变带上两字段。同批重切 motive/traits/arc 口径:动机唯一落motive,traits[]只留核心/表层/阴影三层,arc引用 motive(改character-gen.md→ 已 regenprompt_hashes.json金标准)。codegen 注意:因带default,openapi-typescript 把两字段渲染为必填(响应恒有值),前端CharacterCardView字面量构造点须显式给两字段。→ 影响 @frontend(已pnpm gen:api+CharacterCardItem展示/编辑两字段);未来 ③ 人物塑造审查以 motive/appearance 为客观锚点。 -
[2026-07-06] @backend/@llm/@frontend 改 C3(⑦ 群像按定位配比·缩减版):
CharacterGenerateRequest(schemas/generation.py)加role_mix:dict[str,int]|None——model_validator(mode="after")在场时校验(非空、每项≥1、总和≤MAX_GENERATE_COUNT=12)并把count归一为各值之和(构造期归一化,忽略客户端 count);缺省退回单一role+count。归属纠偏:build_character_gen_context+run_character_gen(ww_core/orchestrator/generation_node.py)=@llm 域,加末位默认role_mix=None关键字参,在场时注入「角色定位配比」块(保插入序、确定性、无时间戳,守 #6/#7;覆盖单一 role 行);routers/generation.py穿role_mix=body.role_mix。character-gen.md加「按配比分配 role」纪律 → 已 regenprompt_hashes.json(仅 character-gen 哈希变,不进 SPECS/不 bump 计数)。前端cards.ts加sanitizeRoleMix/roleMixTotal+buildCharacterGenerateRequest第 4 参 roleMix(在场时 count=Σ、发 role_mix、丢 role);useCharacterGenCharacterGenInput.roleMix穿参;新RoleMixEditor.tsx+CharacterGeneratorSegmentedControl 单一/配比双模。codegen:role_mix渲染?: {[k:string]:number} | null可选。→ 影响 @frontend(已pnpm gen:api)。无迁移。 -
[2026-06-24] @llm/@backend 立 C6-ext(Prompt 外置方案A):21 prompt 散文外置
prompts/<name>.md+load_prompt/SPECS/SCHEMA_CATALOG/REVIEW_RESERVED_NAMES(@llm)+SpecResolver+ SkillRegistry 入库守卫前移 + toolbox 桥接SPECS(@backend)+.gitattributes/wheel package-data/CI 冒烟(@devops)。随后全量重写 21 prompt 内容(任务对齐,schema 契约/不变量保持,金标准 fixture 重生成)。门禁绿:ruff/format/mypy(209)/pytest(744)。→ 影响:后续波次可将编排器 + apps/api 路由切SpecResolver并删*_spec导出;前端零影响(GeneratorTool 描述符字段不变)。 -
[2026-06-23] @backend 立 C-Chain(C2):3 端点 +
schemas/chain.py+services/{chain_runner,chain_deps}.py+jobs零迁移复用(加status="awaiting_input"+JobRepo.set_awaiting)+ 新ErrorCode.CONFLICT(409) +project_deps加build_chain_gateway/get_chain_gateway/get_digest_gateway_builder/get_checkpointer_factory。OpenAPI 含 2 新 POST(GET /jobs 复用)。门禁绿:ruff/format/mypy(193) 干净 + pytest 600 passed + alembic 无漂移。→ 影响 @frontend(follow-uppnpm gen:api)、@db/@devops C3(检查点迁移)。 -
[2026-06-20] @backend 扩 C3:新增
GET /projects/{project_id}/chapters/{chapter_no}/injection(本章注入透明,B0 读端点)→InjectionResponse{project_id, chapter_no, selected:[InjectionEntity{kind,name,reasons[]}], recent_n}(schemas/injection.py,snake_case)。实现仅调既有assemble()回放确定性SelectionTrace——无 LLM、无 commit、无 DDL;项目不存在→404,无大纲→selected:[]。reasons 取值同SelectionReason(explicit_beat/main_character/recent_digest/foreshadow_window)。→ 影响 @frontend(已pnpm gen:api+ChapterAssistant消费)。B0 可控版(PUT override +select_relevant_entities加 pinned/excluded/recent_n + draft 端点同读 override + 持久化)尚未实现,届时再扩本契约。 -
[2026-06-20] @backend 再扩 C3(B0 可控版,接上条):新增
PUT /projects/{project_id}/chapters/{chapter_no}/injection←InjectionOverrideRequest{pinned:[{kind,name}], excluded:[{kind,name}], recent_n:int|null(1..20)}→ 返回InjectionResponse(同上,新增回显字段pinned/excluded,selected[].reasons可含新值author_pin)。语义:pin 强制纳入并加author_pin理由 / excluded 强制剔除(优先于 pin)/ recent_n 覆盖近况回看章数。GET injection 与 draft 流式端点均先读同一覆盖再assemble(override=...),故「看到的=写章用的」(不变量 #6 作者兜底)。持久化:新表chapter_injection(迁移ad2c4c663daf,唯一(project_id,chapter_no),复用 outline 行已否决);select_relevant_entities加pinned/excludedfrozenset 入参、assemble加override关键字参;新domain/injection_repo.py(InjectionOverride/EntityRef/SqlInjectionOverrideRepo,upsert 只 flush 端点 commit)。→ 影响 @frontend(已pnpm gen:api;F1 加 pin/排除控件 + recent_n 步进器)。 -
[2026-06-18] @llm 改 C1:
Gateway.run()现消费LlmRequest.output_schema——OpenAICompatAdapter.complete在 schema 非空时经 instructor(create_with_completion(response_model=...)) 取已校验 Pydantic 实例并填LlmResponse.parsed;无 schema 时parsed is None、纯文本路径不变;记账仍 1 条 usage_ledger/调用(usage 从 raw completion 提取)。ProviderResult新增parsed字段。结构化路径经可注入StructuredClientProtocol 注 fake(测试不联网)。→ 影响 T2.2(续审节点可直接gateway.run(req).parsed)、未来所有结构化输出 Agent。 -
[2026-07-06] @frontend 前端依赖新增(灵感⑥ 关系图谱缩减版 / D4,无后端契约变更、无 gen:api、无迁移):
apps/web/package.json加@xyflow/react@^12.11.2(React Flow,MIT,peer react>=17 兼容 R19)+@dagrejs/dagre@^3.0.0(一次性初始布局,自带 TS 类型)。pnpm install后pnpm-workspace.yaml被 pnpm 自动追加minimumReleaseAgeExclude(@xyflow/react/@xyflow/system 供应链豁免)+pnpm-lock.yaml更新。新增lib/characters/graphLayout.ts(纯逻辑:角色+relations→节点/边+dagre 布局,仅用现有CharacterCardView.relations={name,kind,note}——边按 name-join、kind 作中文标签、节点按 role 上色,无 closeness/阵营,悬空边跳过并计数)+components/characters/RelationshipGraph.tsx('use client',经 CodexPagedynamic(ssr:false)加载,显式容器高度 + import RF CSS + prefers-reduced-motion 关 fitView 动画 + 纸感配色)。消费方:无(叶子特性,挂在设定库人物 tab)。 -
[2026-07-07] @backend/@llm/@frontend 立 C-Rewrite(WFW-8 整章再沟通/重写):新增
POST /projects/{project_id}/chapters/{chapter_no}/rewrite(SSE,text/event-stream,复用 token/done/error 帧契约)←RewriteStreamRequest{feedback:str(1..4000), prior_draft:str(1..200000)}→ 流式重写整章一版。实现:prompts/rewrite.md教条(非 SPEC,仿 write_craft 经load_prompt读盘,test_prompt_loaderdoctrine 排除集加rewrite)+orchestrator/rewrite_node.py(build_rewrite_request纯函数 system=[rewrite教条,stable_core] cache 前缀 / input=近况+当前草稿+作者意见 断点后,守不变量 #9;stream_chapter_rewrite)+ 端点复用assemble()记忆注入 +normalize_deltas。只读不写库(HITL:新版停前端,接受才落,不变量 #3)、工具非写节点(不破坏 章=f(outline,state) 纯函数,不变量 #7)、项目不存在触网关前 404。无 DDL/无迁移。门禁绿:ruff/mypy(224)/pytest(+test_rewrite_node 5 例)/alembic 无漂移。→ 影响 @frontend(已pnpm gen:api;useChapterRewriteSSE hook +ChapterRewritePanel版本栈 + Workbench「整章重写」入口)。 -
[2026-07-08] @backend 改 C3(CR-H9 输入大小上界 + 请求体守卫):给用户请求字符串字段补
max_length(Field(...,max_length=)/StringConstraints(max_length=),保留既有min_length,命名常量无魔数)——providers.py{provider≤64,model≤128,api_key≤512}、generation.py{brief≤10_000}、projects.py{title≤200,final_text/draft/text≤200_000}、style.py{segment≤20_000,samples 列表≤20 且每条≤20_000}、foreshadow.py{code≤100,title≤500}、rules.py{content≤10_000}、templates.py{NonBlankStr→NonBlankTitle(≤200)+NonBlankBody(≤20_000)};ProjectPlanGenerateRequest/RewriteStreamRequest/RefineClarifyRequest早前已有上界,未动。新增错误码ErrorCode.PAYLOAD_TOO_LARGE(413)(ww_shared.errors,ErrorBody.code为裸 str → OpenAPI schema 不变)+ 应用级body_size_limit_middleware(apps/api/ww_api/middleware.py,MAX_BODY_BYTES=2 MiB,按Content-Length拦超大 body 直接自建 413 信封——用户中间件跑在 AppError 处理器外;chunked 无 Content-Length 为原型可接受限制)+main.py在request_id_middleware后注册。无 DDL/无迁移。超界 → 422(字段 ValidationError)/ 413(整体 body)。→ 影响 @frontend:请求字段现有maxLength,须pnpm gen:api重生成 TS 客户端(本 cluster 不代跑)。 -
[2026-07-08] @backend/@llm/@frontend 立 C-Clarify(WFW-9 M1 refine 侧 AI 反问澄清,路线A 两阶段):新增
POST /projects/{project_id}/chapters/{chapter_no}/refine/clarify(非流式 JSON 预检)←RefineClarifyRequest{segment:str(1..20000), instruction:str(默认"",0..2000)}→ClarifyDecision{need_clarification:bool, questions:[ClarifyQuestion{question, options:ClarifyOption{label,value}, allow_free_text:bool}](0或1,v1硬上限1问), verification:str|null}。tier=analyst,结构化输出(instructor),只读不写库(末尾 commit 仅记 usage_ledger,不变量 #3);判别/校验失败确定性回退 need_clarification=false(放行)。ClarifyDecision定义在ww_agents(供 producer+端点共用),注册clarify_refine_spec(SPECS #24)+SCHEMA_CATALOG+金标准。既有POST .../refine(RefineRequest/RefineResponse)完全未改。→ 影响 @frontend(已 gen:api;useClarify映射 snake→VM +ChoiceChips选项芯片 +RefinePanel门控预检:意见<10字或点按钮→预检→needClarification 则渲染选项→答案 foldClarifications 折进 instruction→走既有 refine)。无 DDL/无迁移。门禁绿:后端 ruff/mypy227/pytest900/alembic;前端 tsc/lint/vitest654/build/cov95.36%。M2(rewrite 侧两阶段)/M3(UX+E2E) 待办。 -
[2026-07-08] @llm/@backend 扩 C-Clarify(WFW-9 M2 rewrite 侧 AI 反问澄清,镜像 M1):新增
POST /projects/{project_id}/chapters/{chapter_no}/rewrite/clarify(非流式 JSON 预检)←RewriteClarifyRequest{feedback:str(1..4000), prior_draft:str|null(0..200000)}→ClarifyDecision(复用 M1 同一 schema:{need_clarification, questions[0或1,硬上限1问], verification?})。tier=analyst,结构化输出,只读不写业务表(末尾 commit 仅记 usage_ledger,不变量 #3);判别/校验失败确定性回退 need_clarification=false(放行)。端点只把prior_draft开头 3000 字摘录喂预检(不整章入 LLM),日志只记feedback_len(脱敏)。注册clarify_rewrite_spec(analyst,reads=()/writes=(),SPECS #25)+SCHEMA_CATALOG(复用 ClarifyDecision)+教条prompts/clarify_rewrite.md(segment→整章)+金标准(仅新增 1 条哈希,无漂移)。既有POST .../rewrite(RewriteStreamRequest, SSE)完全未改。→ 影响 @frontend:须pnpm gen:api纳入新端点,再仿 M1RefinePanel在ChapterRewritePanel接门控预检(意见含糊/极短→预检→needClarification 则选项芯片→折答案进 feedback→走既有 rewrite SSE)。无 DDL/无迁移。门禁绿:后端 ruff/format 干净·mypy 229·pytest 948·alembic 无漂移。M2 前端 UI 接线 + M3(UX/E2E) 待办。