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

@@ -0,0 +1,427 @@
"""生成/入库端点C3 扩 / ARCH §6.5 角色生成器 / §5.4 / §7.2;不变量 #1/#3/#9
三类端点 + 两个读端点M5-d 生成走即时返回:预览 → 作者确认 → 入库,非 jobs
- `POST /projects/:id/world/generate`worldbuilderwriter 网关)→ **预览**世界观实体(不入库)。
- `POST /projects/:id/characters/generate`character-genwriter 网关,群像防雷同)→ 预览角色卡。
- `POST /projects/:id/characters`:作者确认的角色卡入库——**入库前 gate**
1. `precheck_generated_cards`continuity 预检analyst 网关)比对生成卡 vs 真相源;
有冲突且未 `acknowledge_conflicts` → **409 CONFLICT_UNRESOLVED**(不静默入库,仿 accept gate
守不变量 #3作者确认后放行。
2. `partition_writes`:按 character-gen 声明的 `writes` 白名单过滤(越权表丢弃 + 审计),守 §5.6。
3. 写 `characters` 行schema list/str → DB JSONB dict 形变,见 character_repo
- `GET /projects/:id/rules`规则列表规则页T5.6)。
- `GET /skills`:技能库列表(技能库 UIT5.6)。
提交边界:网关 ledger + 角色写侧均只 flush → 端点末尾一次 `commit()`(生成预览也 commit
ledger否则 usage 静默丢失,同 draft/review 纪律)。无凭据 → `LLM_UNAVAILABLE`503
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import (
CharacterCard,
CharacterRelation,
character_gen_spec,
continuity_spec,
worldbuilder_spec,
)
from ww_core.domain import (
CharacterWriteRepo,
ProjectRepo,
)
from ww_core.domain.repositories import MemoryRepos, RulesRepo
from ww_core.orchestrator import (
precheck_generated_cards,
run_character_gen,
run_worldbuilder,
)
from ww_db import get_session
from ww_llm_gateway import Gateway
from ww_shared import AppError, ErrorCode
from ww_skills import SkillRegistry, partition_writes
from ww_api.logging_config import get_logger
from ww_api.schemas.generation import (
CharacterCardView,
CharacterGenerateRequest,
CharacterGenPreviewResponse,
CharacterIngestRequest,
CharacterIngestResponse,
CharacterListResponse,
CharacterRelationView,
IngestConflictView,
RuleListResponse,
SkillListResponse,
SkillView,
WorldEntityCardView,
WorldEntityListResponse,
WorldGenerateRequest,
WorldGenPreviewResponse,
)
from ww_api.schemas.rules import RuleView
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import (
get_character_gen_gateway,
get_character_write_repo,
get_memory_repos,
get_precheck_gateway,
get_project_repo,
get_rules_read_repo,
get_skill_registry,
get_worldbuilder_gateway,
)
log = get_logger("ww.api.generation")
router = APIRouter(prefix="/projects", tags=["generation"])
skills_router = APIRouter(prefix="/skills", tags=["skills"])
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
CharacterWriteRepoDep = Annotated[CharacterWriteRepo, Depends(get_character_write_repo)]
RulesReadRepoDep = Annotated[RulesRepo, Depends(get_rules_read_repo)]
SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)]
WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)]
CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)]
PrecheckGatewayDep = Annotated[Gateway, Depends(get_precheck_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
def _project_context(title: str, genre: str | None, premise: str | None, theme: str | None) -> str:
"""确定性序列化作品设定(喂 worldbuilder无时间戳/UUID"""
lines = [f"标题:{title}"]
if genre:
lines.append(f"题材:{genre}")
if premise:
lines.append(f"前提:{premise}")
if theme:
lines.append(f"主题:{theme}")
return "\n".join(lines)
def _render_characters_context(cards: list[CharacterCard]) -> str:
"""已有角色简表(喂 character-gen 防雷同 + precheck 真相源)。确定性、保序。"""
if not cards:
return ""
return "\n".join(f"- {c.name}{c.role}{''.join(c.traits) or '(未列)'}" for c in cards)
async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> list[CharacterCard]:
"""把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck
DB JSONB dict 列 → schema list/str 反向解包(与写侧形变互逆;缺则空/占位)。
"""
views = await memory.character.list_for_project(project_id)
cards: list[CharacterCard] = []
for v in views:
traits = list((v.traits or {}).get("items", [])) if isinstance(v.traits, dict) else []
tics = (
list((v.speech_tics or {}).get("items", [])) if isinstance(v.speech_tics, dict) else []
)
arc = (v.arc or {}).get("text", "") if isinstance(v.arc, dict) else ""
cards.append(
CharacterCard(
name=v.name,
role=v.role or "",
traits=traits,
backstory=v.backstory or "",
arc=arc or "",
speech_tics=tics,
tags=list(v.tags or []),
relations=[],
)
)
return cards
async def _world_context(memory: MemoryRepos, project_id: uuid.UUID) -> str:
"""已有世界观硬规则简表(喂 character-gen / precheck 真相源)。"""
views = await memory.world_entity.list_for_project(project_id)
lines: list[str] = []
for v in views:
rules = list((v.rules or {}).get("rules", [])) if isinstance(v.rules, dict) else []
rule_text = "".join(rules) if rules else "(无硬规则)"
lines.append(f"- [{v.type}] {v.name}{rule_text}")
return "\n".join(lines)
# ---- 世界观生成(预览,不入库)----
@router.post("/{project_id}/world/generate")
async def generate_world(
project_id: uuid.UUID,
body: WorldGenerateRequest,
request: Request,
project_repo: ProjectRepoDep,
gateway: WorldGatewayDep,
session: SessionDep,
) -> WorldGenPreviewResponse:
"""生成世界观实体预览(不持久化;作者确认后另入库)。无凭据 → 503项目不存在 → 404。"""
request_id = getattr(request.state, "request_id", None)
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
project_context = _project_context(project.title, project.genre, project.premise, project.theme)
result = await run_worldbuilder(
worldbuilder_spec,
brief=body.brief,
project_context=project_context,
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
# 提交边界:预览不写业务表,但网关 ledger 只 flush → 末尾 commit 落 usage否则丢失
await session.commit()
log.info(
"world_generate_done",
project_id=str(project_id),
request_id=request_id,
entity_count=len(result.entities),
)
return WorldGenPreviewResponse(
entities=[
WorldEntityCardView(type=e.type, name=e.name, rules=list(e.rules))
for e in result.entities
]
)
# ---- 角色生成(预览,不入库;群像防雷同)----
@router.post("/{project_id}/characters/generate")
async def generate_characters(
project_id: uuid.UUID,
body: CharacterGenerateRequest,
request: Request,
project_repo: ProjectRepoDep,
memory: MemoryReposDep,
gateway: CharacterGatewayDep,
session: SessionDep,
) -> CharacterGenPreviewResponse:
"""生成角色卡预览(不持久化;注入已有角色防雷同)。无凭据 → 503项目不存在 → 404。"""
request_id = getattr(request.state, "request_id", None)
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
existing = await _existing_characters(memory, project_id)
world_context = await _world_context(memory, project_id)
result = await run_character_gen(
character_gen_spec,
brief=body.brief,
count=body.count,
role=body.role,
world_context=world_context,
existing_chars=existing,
generated_so_far=[],
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
await session.commit()
log.info(
"characters_generate_done",
project_id=str(project_id),
request_id=request_id,
card_count=len(result.cards),
)
return CharacterGenPreviewResponse(cards=[_card_to_view(c) for c in result.cards])
# ---- 角色入库作者确认后continuity gate + 权限白名单)----
@router.post("/{project_id}/characters", status_code=201)
async def ingest_characters(
project_id: uuid.UUID,
body: CharacterIngestRequest,
request: Request,
project_repo: ProjectRepoDep,
memory: MemoryReposDep,
char_repo: CharacterWriteRepoDep,
gateway: PrecheckGatewayDep,
session: SessionDep,
) -> CharacterIngestResponse:
"""入库作者确认的角色卡。有 continuity 冲突且未确认 → 409越权写表丢弃 + 审计。"""
request_id = getattr(request.state, "request_id", None)
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
cards = [_view_to_card(v) for v in body.cards]
# gate 1入库前 continuity 预检(编排器追加的检查,非 character-gen 互调,守不变量 #1
world_context = await _world_context(memory, project_id)
existing = await _existing_characters(memory, project_id)
conflicts = await precheck_generated_cards(
continuity_spec,
cards=cards,
world_context=world_context,
characters_context=_render_characters_context(existing),
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
if conflicts and not body.acknowledge_conflicts:
# 仿 accept 的冲突 gate不静默入库须作者裁决/确认(不变量 #3
await session.commit() # 落 precheck 网关 usage否则丢失不写业务表。
log.info(
"characters_ingest_blocked_by_conflicts",
project_id=str(project_id),
request_id=request_id,
conflict_count=len(conflicts),
)
raise AppError(
ErrorCode.CONFLICT_UNRESOLVED,
"生成角色卡与现有设定存在 continuity 冲突,请裁决后确认入库",
{
"conflicts": [
IngestConflictView(
type=c.type, where=c.where, refs=list(c.refs), suggestion=c.suggestion
).model_dump()
for c in conflicts
],
"conflict_count": len(conflicts),
},
)
# gate 2权限白名单——character-gen 只声明 writes=["characters"],越权产出丢弃 + 审计。
allowed, rejected = partition_writes(character_gen_spec, {"characters": cards})
if rejected:
log.warning(
"characters_ingest_rejected_over_permission",
project_id=str(project_id),
request_id=request_id,
rejected_tables=rejected,
)
created: list[str] = []
for card in allowed.get("characters", []):
view = await char_repo.create(
project_id,
name=card.name,
role=card.role,
traits=list(card.traits),
backstory=card.backstory,
arc=card.arc,
speech_tics=list(card.speech_tics),
tags=list(card.tags),
relations=[r.model_dump() for r in card.relations],
)
created.append(view.name)
# 提交边界:网关 ledgerprecheck+ 角色写侧均只 flush → 端点末尾一次 commit。
await session.commit()
log.info(
"characters_ingest_done",
project_id=str(project_id),
request_id=request_id,
created_count=len(created),
)
return CharacterIngestResponse(created=created, rejected_tables=rejected)
# ---- 读端点:规则列表 + 技能库T5.6 前端)----
@router.get("/{project_id}/characters")
async def list_characters(
project_id: uuid.UUID,
memory: MemoryReposDep,
) -> CharacterListResponse:
"""已入库角色全量列表(设定库 Codex 真源;复用 C5 读侧 SqlCharacterRepo
DB JSONB dict 列 → API list/str 反向解包(入库形变的逆向,见 `_existing_characters`)。
"""
cards = await _existing_characters(memory, project_id)
return CharacterListResponse(characters=[_card_to_view(c) for c in cards])
@router.get("/{project_id}/world_entities")
async def list_world_entities(
project_id: uuid.UUID,
memory: MemoryReposDep,
) -> WorldEntityListResponse:
"""已入库世界观实体全量列表(设定库 Codex 真源;复用 C5 读侧 SqlWorldEntityRepo
DB `rules` JSONB dict `{"rules":[...]}` → 裸 listworldbuilder 形变的逆向)。
"""
views = await memory.world_entity.list_for_project(project_id)
entities = [
WorldEntityCardView(
type=v.type,
name=v.name,
rules=(list((v.rules or {}).get("rules", [])) if isinstance(v.rules, dict) else []),
)
for v in views
]
return WorldEntityListResponse(world_entities=entities)
@router.get("/{project_id}/rules")
async def list_rules(
project_id: uuid.UUID,
repo: RulesReadRepoDep,
) -> RuleListResponse:
"""规则列表(按读侧顺序)。规则页用。"""
rules = await repo.all_for_project(project_id)
return RuleListResponse(rules=[RuleView(level=r.level, content=r.content) for r in rules])
@skills_router.get("")
async def list_skills(registry: SkillRegistryDep) -> SkillListResponse:
"""技能库列表(按 name 升序)。技能库 UI 用。"""
skills = [
SkillView(
name=spec.name,
scope=spec.scope,
tier=spec.tier,
reads=list(spec.reads),
writes=list(spec.writes),
genre=spec.genre,
)
# registry.names() 已排序;按名取 spec 保证确定性顺序。
for spec in (registry.get(name) for name in registry.names())
]
return SkillListResponse(skills=skills)
# ---- schema <-> ww_agents 卡转换 ----
def _card_to_view(card: CharacterCard) -> CharacterCardView:
return CharacterCardView(
name=card.name,
role=card.role,
traits=list(card.traits),
backstory=card.backstory,
arc=card.arc,
speech_tics=list(card.speech_tics),
tags=list(card.tags),
relations=[
CharacterRelationView(name=r.name, kind=r.kind, note=r.note) for r in card.relations
],
)
def _view_to_card(view: CharacterCardView) -> CharacterCard:
return CharacterCard(
name=view.name,
role=view.role,
traits=list(view.traits),
backstory=view.backstory,
arc=view.arc,
speech_tics=list(view.speech_tics),
tags=list(view.tags),
relations=[
CharacterRelation(name=r.name, kind=r.kind, note=r.note) for r in view.relations
],
)

View File

@@ -0,0 +1,190 @@
"""Kimi Code OAuth device-flow 端点C3 扩 K1.3 / ARCH §7.2 / §7.4 jobs
订阅 plan device-flow 登录:作者在设置页点「连接 Kimi Code」→ 后端发起 device
authorization → 返回 202 `{job_id, user_code, verification_uri, ...}` → 前端展示 user_code
+ 打开授权页 + 轮询 `GET /jobs/{id}`;后端经 BackgroundTask `run_job` **后台轮询** token
端点直到授权成功(加密存 `oauth_enc` + job done或过期/拒绝job failed
三端点(挂 `kimi_oauth.router`,已注册):
- `POST /settings/providers/kimi-code/oauth/start` → 202 `OAuthStartResponse`
- `POST /settings/providers/kimi-code/oauth/disconnect` → 200 `OAuthDisconnectResponse`
- `GET /settings/providers/kimi-code/oauth/status` → 200 `OAuthStatusResponse`
**token 绝不出边界**:响应/job 结果/日志只含 user_code/connected 等非密信息access/refresh
token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`。
"""
from __future__ import annotations
import asyncio
from typing import Annotated, Any
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_core.domain import JobRepo
from ww_db import get_session
from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_PROVIDER
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.kimi_oauth import (
OAuthDisconnectResponse,
OAuthStartResponse,
OAuthStatusResponse,
)
from ww_api.services.credentials import (
AUTH_TYPE_OAUTH,
STUB_OWNER_ID,
CredentialStore,
SqlCredentialStore,
)
from ww_api.services.foreshadow_scan import SessionFactory
from ww_api.services.job_runner import run_job
from ww_api.services.kimi_oauth import (
AsyncHttpClient,
AuthorizationPending,
DeviceAuth,
SlowDown,
decrypt_oauth_bundle,
encrypt_oauth_bundle,
poll_token,
start_device_authorization,
)
from ww_api.services.project_deps import get_job_repo, get_session_factory
from ww_api.services.provider_deps import get_credential_store
log = get_logger("ww.api.kimi_oauth")
router = APIRouter(prefix="/settings/providers/kimi-code/oauth", tags=["kimi-oauth"])
CredentialStoreDep = Annotated[CredentialStore, Depends(get_credential_store)]
JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
_JOB_KIND_KIMI_OAUTH = "kimi_oauth"
#: device flow 轮询安全上限(防止 work 在异常 interval 下无限循环;按 expires_in 兜底)。
_MAX_POLL_ATTEMPTS = 200
def _default_http_client() -> AsyncHttpClient:
return httpx.AsyncClient(timeout=30.0)
def _make_poll_work(device: DeviceAuth) -> Any:
"""构造后台轮询工作闭包:循环 poll token 直到成功/过期/拒绝。
`work(session)` 自建 httpx 客户端 + 凭据 store用 `run_job` 传入的独立 session
按 `interval` 轮询 token → 成功则加密存 `oauth_enc` 并返回非密 job 结果 → 过期/拒绝则
抛 AppError`run_job` 置 job failed。**token 绝不进 job 结果/日志**。
"""
async def work(session: AsyncSession) -> dict[str, Any]:
enc_key = get_settings().credential_enc_key
store = SqlCredentialStore(session)
interval = max(1, device.interval)
http = _default_http_client()
try:
for _ in range(_MAX_POLL_ATTEMPTS):
await asyncio.sleep(interval)
try:
token = await poll_token(http, device.device_code)
except AuthorizationPending:
continue
except SlowDown:
interval += 5
continue
# 成功:加密 token 包入库auth_type=oauth。明文 token 不进结果/日志。
blob = encrypt_oauth_bundle(token, key=enc_key)
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, blob)
return {"connected": True, "provider": KIMI_CODE_PROVIDER}
# 轮询次数耗尽(视作过期)。
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
"Kimi 设备授权轮询超时",
{"provider": KIMI_CODE_PROVIDER},
)
finally:
# `httpx.AsyncClient` 有 `aclose`(最小 Protocol 无;测试 fake 也实现了它)。
aclose = getattr(http, "aclose", None)
if aclose is not None:
await aclose()
return work
@router.post("/start", status_code=202)
async def start_oauth(
request: Request,
response: Response,
background_tasks: BackgroundTasks,
job_repo: JobRepoDep,
session: SessionDep,
session_factory: SessionFactoryDep,
http: Annotated[AsyncHttpClient, Depends(_default_http_client)],
) -> OAuthStartResponse:
"""发起 device authorization返回 202 + user_code/verification_uri后台轮询 token。
创建一行 `jobs(kind="kimi_oauth")`202 前持久化供轮询)→ 调度 BackgroundTask 后台
轮询。前端展示 user_code + 打开 verification_uri + 轮询 `GET /jobs/{id}`。
"""
request_id = getattr(request.state, "request_id", None)
device = await start_device_authorization(http)
job = await job_repo.create(None, _JOB_KIND_KIMI_OAUTH)
await session.commit() # job 行需在 202 前持久化(供前端立即轮询)。
background_tasks.add_task(
run_job,
session_factory,
job.id,
_make_poll_work(device),
request_id=request_id,
)
log.info(
"kimi_oauth_started",
request_id=request_id,
job_id=str(job.id),
user_code=device.user_code,
)
response.status_code = 202
return OAuthStartResponse(
job_id=job.id,
user_code=device.user_code,
verification_uri=device.verification_uri,
verification_uri_complete=device.verification_uri_complete,
expires_in=device.expires_in,
interval=device.interval,
)
@router.post("/disconnect")
async def disconnect_oauth(
request: Request,
store: CredentialStoreDep,
) -> OAuthDisconnectResponse:
"""断开 Kimi Code删除 OAuth 凭据行token 一并消失)。"""
request_id = getattr(request.state, "request_id", None)
deleted = await store.delete_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER)
log.info("kimi_oauth_disconnected", request_id=request_id, deleted=deleted)
return OAuthDisconnectResponse(disconnected=deleted)
@router.get("/status")
async def oauth_status(store: CredentialStoreDep) -> OAuthStatusResponse:
"""连接状态:是否已连接 + access token 过期时刻(**无 token 本体**)。"""
enc_key = get_settings().credential_enc_key
cred = await store.get_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER)
if cred is None or cred.auth_type != AUTH_TYPE_OAUTH or cred.oauth_enc is None:
return OAuthStatusResponse(connected=False)
try:
token = decrypt_oauth_bundle(cred.oauth_enc, key=enc_key)
except Exception: # noqa: BLE001 — 解密失败视作未连接(不泄露原因到响应)
return OAuthStatusResponse(connected=False)
return OAuthStatusResponse(connected=True, expires_at=token.expires_at.isoformat())

View File

@@ -22,7 +22,7 @@ from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import outliner_spec
from ww_core.domain import ForeshadowLedgerRepo, OutlineWriteRepo, ProjectRepo
from ww_core.domain.repositories import MemoryRepos
from ww_core.domain.repositories import MemoryRepos, OutlineRepo
from ww_core.orchestrator import run_outline
from ww_db import get_session
from ww_llm_gateway import Gateway
@@ -41,6 +41,7 @@ from ww_api.services.project_deps import (
get_foreshadow_repo,
get_memory_repos,
get_outline_gateway,
get_outline_read_repo,
get_outline_write_repo,
get_project_repo,
)
@@ -53,6 +54,7 @@ ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)]
OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)]
OutlineGatewayDep = Annotated[Gateway, Depends(get_outline_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -133,3 +135,42 @@ async def generate_outline(
chapter_count=len(chapters),
)
return OutlineResponse(chapters=chapters)
@router.get("/{project_id}/outline")
async def get_outline(
project_id: uuid.UUID,
request: Request,
project_repo: ProjectRepoDep,
outline_repo: OutlineReadRepoDep,
) -> OutlineResponse:
"""读取已持久化的大纲(逐章,按 chapter_no 升序)。
项目不存在 → 404项目存在但尚无大纲 → 200 空列表(非 404页面初次访问的常态
DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形,
前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。
"""
request_id = getattr(request.state, "request_id", None)
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
views = await outline_repo.list_for_project(project_id)
chapters = [
OutlineChapterView(
no=view.chapter_no,
volume=view.volume,
beats=list(view.beats.get("beats", [])),
foreshadow_windows=[ForeshadowWindowView(**w) for w in view.foreshadow_windows],
)
for view in views
]
log.info(
"outline_read",
project_id=str(project_id),
request_id=request_id,
chapter_count=len(chapters),
)
return OutlineResponse(chapters=chapters)

View File

@@ -45,6 +45,7 @@ from ww_api.schemas.projects import (
AcceptResponse,
DraftResponse,
DraftSaveRequest,
DraftView,
ProjectCreateRequest,
ProjectListResponse,
ProjectResponse,
@@ -198,6 +199,41 @@ async def save_draft(
)
@router.get("/{project_id}/chapters/{chapter_no}/draft")
async def get_draft(
project_id: uuid.UUID,
chapter_no: int,
repo: ChapterRepoDep,
) -> DraftView:
"""读取已保存草稿(含正文),供工作台重访时重载编辑器(镜像 GET /outline 读侧)。
复用 `chapter_repo.get_draft`(与续审 `_resolve_review_draft` 同一读 seam只读不写库。
无草稿行(含空正文)→ 404 NOT_FOUND工作台据此呈现空编辑器与其它「缺资源」端点一致
多版本时该读取固定返草稿版次DRAFT_VERSION这条可编辑工作副本与 PUT 保存的同一行。
"""
view = await repo.get_draft(project_id, chapter_no)
if view is None or not view.content.strip():
raise AppError(
ErrorCode.NOT_FOUND,
f"chapter {chapter_no} has no saved draft",
)
log.info(
"draft_read",
project_id=str(project_id),
chapter_no=chapter_no,
length=len(view.content),
)
return DraftView(
project_id=view.project_id,
chapter_no=view.chapter_no,
volume=view.volume,
status=view.status,
version=view.version,
content=view.content,
length=len(view.content),
)
async def _resolve_review_draft(
body: ReviewRequest,
chapter_repo: ChapterRepo,

View File

@@ -0,0 +1,53 @@
"""规则端点C3 扩 / PRODUCT_SPEC §7 `POST /projects/:id/rules`;不变量 #3
POST /projects/:id/rules作者显式加规则——审稿发现的问题/亮点随手沉淀为项目规则。
写一行 `rules`level + content喂给 assemble 的四级合并(`merge_rules`)。
加规则是**作者显式动作**(不变量 #3规则入库不经 AI 静默写库)。`level` 合法性由
`RuleCreateRequest` 的 Literal 校验(非法 → FastAPI 422
提交边界:`RuleWriteRepo.create` 只 `flush()`,端点写后 `await session.commit()`
(仿 foreshadow/outline 写侧,见 memory/gotchas
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain import RuleWriteRepo
from ww_db import get_session
from ww_api.logging_config import get_logger
from ww_api.schemas.rules import RuleCreateRequest, RuleView
from ww_api.services.project_deps import get_rule_write_repo
log = get_logger("ww.api.rules")
router = APIRouter(prefix="/projects", tags=["rules"])
RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@router.post("/{project_id}/rules", status_code=201)
async def create_rule(
project_id: uuid.UUID,
body: RuleCreateRequest,
request: Request,
repo: RuleWriteRepoDep,
session: SessionDep,
) -> RuleView:
"""新增一条规则201。非法 level / 空 content → FastAPI 422。"""
request_id = getattr(request.state, "request_id", None)
view = await repo.create(project_id, level=body.level, content=body.content)
await session.commit()
log.info(
"rule_created",
project_id=str(project_id),
request_id=request_id,
level=body.level,
)
return RuleView(level=view.level, content=view.content)

View File

@@ -0,0 +1,225 @@
"""文风端点:学文风(异步长任务)+ 回炉 + 最新指纹读取
C3 扩 / ARCH §5.4 style-auditor 提取轨 / §7.2 / §7.4 jobs / UX §6.9 / §8.3)。
独立 router仿 outline.py挂 `app.include_router`。三端点:
- `POST /projects/{id}/style` → 202 `{job_id}`:学文风走 jobsM4-c。立即写一行 job
返 202提取经 BackgroundTask `run_job` 跑(**自建独立 session**,请求 session 已关闭)。
无凭据 → 流前 `LLM_UNAVAILABLE`503在调度 job 之前拦下,避免凭空写注定失败的 job
- `POST /projects/{id}/chapters/{no}/refine` → 200 `{original, refined}`同步回炉writer
网关纯文本重写选中段;不写库(不变量 #3作者采纳经既有 draft 自动保存合入);末尾
`commit()` 让 usage_ledger 落库(网关 ledger add-only同 draft/review 纪律)。
- `GET /projects/{id}/style` → 200 最新指纹(完整 16 维 + 证据 + 版本UX §6.9)。
不变量 #2经 `get_*_gateway`analyst/writer 档注入spec 只声明档位不传 model。
不变量 #3提取/回炉 agent 只读不写其它表;文风指纹落库经此作者发起的学文风路径。
"""
from __future__ import annotations
import uuid
from typing import Annotated, Any
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import refiner_spec, style_extract_spec
from ww_core.domain import JobRepo, ProjectRepo, StyleFingerprintWriteRepo
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo
from ww_core.orchestrator import run_style_extraction
from ww_db import get_session
from ww_llm_gateway import Gateway
from ww_llm_gateway.types import Block, LlmRequest, Scope
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.style import (
RefineRequest,
RefineResponse,
StyleFingerprintResponse,
StyleLearnRequest,
StyleLearnResponse,
)
from ww_api.services.credentials import STUB_OWNER_ID, SqlCredentialStore
from ww_api.services.foreshadow_scan import SessionFactory
from ww_api.services.job_runner import run_job
from ww_api.services.project_deps import (
build_gateway_for_tier,
get_job_repo,
get_project_repo,
get_refine_gateway,
get_session_factory,
get_style_extract_gateway,
get_style_write_repo,
)
log = get_logger("ww.api.style")
router = APIRouter(prefix="/projects", tags=["style"])
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
StyleWriteRepoDep = Annotated[StyleFingerprintWriteRepo, Depends(get_style_write_repo)]
StyleExtractGatewayDep = Annotated[Gateway, Depends(get_style_extract_gateway)]
RefineGatewayDep = Annotated[Gateway, Depends(get_refine_gateway)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
_JOB_KIND_STYLE_LEARN = "style_learn"
def _split_fingerprint(result: Any) -> tuple[dict[str, str], dict[str, list[str]]]:
"""把 `StyleFingerprintResult` 拆成 DB 两列:`{name:value}` + `{name:[evidence]}`。"""
dimensions = {dim.name: dim.value for dim in result.dimensions}
evidence = {dim.name: list(dim.evidence) for dim in result.dimensions}
return dimensions, evidence
def _make_style_learn_work(project_id: uuid.UUID, samples_text: str) -> Any:
"""构造学文风后台工作闭包:在 `run_job` 自建的独立 session 上跑提取 + 落库。
`work(session)` 自建 analyst 网关(从凭据)+ 写侧 repo用 `run_job` 传入的独立
session→ `run_style_extraction` → 拆指纹 → `append`(版本化)→ 返回 result 摘要
`{version, dims_count}`。提交归 `run_job`(业务写 + job done 同一事务一次 commit
"""
async def work(session: AsyncSession) -> dict[str, Any]:
store = SqlCredentialStore(session)
gateway = await build_gateway_for_tier(session, store, "analyst")
result = await run_style_extraction(
style_extract_spec,
samples_text=samples_text,
gateway=gateway,
user_id=STUB_OWNER_ID,
project_id=project_id,
)
dimensions, evidence = _split_fingerprint(result)
repo = SqlStyleFingerprintWriteRepo(session)
version = await repo.append(project_id, dimensions_json=dimensions, evidence_json=evidence)
return {"version": version, "dims_count": len(dimensions)}
return work
@router.post("/{project_id}/style", status_code=202)
async def learn_style(
project_id: uuid.UUID,
body: StyleLearnRequest,
request: Request,
response: Response,
background_tasks: BackgroundTasks,
project_repo: ProjectRepoDep,
job_repo: JobRepoDep,
gateway: StyleExtractGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503
session: SessionDep,
session_factory: SessionFactoryDep,
) -> StyleLearnResponse:
"""学文风:写一行 job 返 202提取经 BackgroundTask 异步跑。
项目不存在 → 404无凭据 → 503在调度 job 前拦下)。`mode` 不影响落库逻辑
(写侧始终 append 新版本version+1 自动),仅供前端区分首学/更新语义。
"""
request_id = getattr(request.state, "request_id", None)
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
# `gateway` 已在 dep 解析阶段验过凭据(无凭据 → 503提取本体在 BackgroundTask
# 里用独立 session 重新构网关跑,此处不复用它(请求 session 即将关闭)。
samples_text = "\n\n".join(body.samples)
job = await job_repo.create(project_id, _JOB_KIND_STYLE_LEARN)
await session.commit() # job 行需在 202 返回前持久化(供前端立即轮询)。
background_tasks.add_task(
run_job,
session_factory,
job.id,
_make_style_learn_work(project_id, samples_text),
request_id=request_id,
)
log.info(
"style_learn_scheduled",
project_id=str(project_id),
request_id=request_id,
job_id=str(job.id),
sample_count=len(body.samples),
mode=body.mode,
)
response.status_code = 202
return StyleLearnResponse(job_id=job.id)
@router.get("/{project_id}/style")
async def get_style(
project_id: uuid.UUID,
project_repo: ProjectRepoDep,
style_repo: StyleWriteRepoDep,
) -> StyleFingerprintResponse:
"""取最新文风指纹(完整 16 维 + 证据 + 版本)。项目不存在 → 404无指纹 → 404。"""
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
latest = await style_repo.latest(project_id)
if latest is None:
raise AppError(ErrorCode.NOT_FOUND, f"no style fingerprint for project: {project_id}")
return StyleFingerprintResponse(
dimensions=latest.dimensions,
evidence=latest.evidence,
version=latest.version,
)
def _build_refine_input(segment: str, instruction: str | None) -> str:
"""组回炉输入:待重写段 + 可选指令(保留上下文语气,只重写该段)。"""
parts = [f"【待重写段落】\n{segment}"]
if instruction:
parts.append(f"【改写指令】\n{instruction}")
return "\n\n".join(parts)
@router.post("/{project_id}/chapters/{chapter_no}/refine")
async def refine_segment(
project_id: uuid.UUID,
chapter_no: int,
body: RefineRequest,
request: Request,
project_repo: ProjectRepoDep,
gateway: RefineGatewayDep,
session: SessionDep,
) -> RefineResponse:
"""同步回炉writer 网关纯文本重写选中段,返回 {original, refined}。
项目不存在 → 404无凭据 → 503。不写库不变量 #3末尾 `commit()` 让网关
usage_ledger 落库add-only否则记账静默丢失同 draft/review 纪律)。
"""
request_id = getattr(request.state, "request_id", None)
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
req = LlmRequest(
tier=refiner_spec.tier, # 不变量 #2writer 档,不传 model
system=[Block(text=refiner_spec.system_prompt, cache=True)],
input=_build_refine_input(body.segment, body.instruction),
output_schema=None, # refiner 纯文本(无结构化 schema
scope=Scope(user_id=STUB_OWNER_ID, project_id=project_id),
)
resp = await gateway.run(req)
# 提交边界:网关 ledger add-only → 端点末尾 commit否则记账静默丢失见 gotcha
await session.commit()
log.info(
"refine_done",
project_id=str(project_id),
chapter_no=chapter_no,
request_id=request_id,
segment_len=len(body.segment),
refined_len=len(resp.text),
has_instruction=body.instruction is not None,
)
return RefineResponse(original=body.segment, refined=resp.text)