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:
@@ -12,7 +12,7 @@ from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from ww_core.domain import ForeshadowLedgerView, OutlineWriteView
|
||||
from ww_core.domain import ForeshadowLedgerView, OutlineWriteView, StyleFingerprintView
|
||||
from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView
|
||||
from ww_core.domain.foreshadow_state import (
|
||||
ForeshadowStatus,
|
||||
@@ -21,8 +21,16 @@ from ww_core.domain.foreshadow_state import (
|
||||
from ww_core.domain.foreshadow_state import (
|
||||
transition as apply_transition,
|
||||
)
|
||||
from ww_core.domain.job_repo import (
|
||||
PROGRESS_COMPLETE,
|
||||
STATUS_DONE,
|
||||
STATUS_FAILED,
|
||||
STATUS_QUEUED,
|
||||
STATUS_RUNNING,
|
||||
JobView,
|
||||
)
|
||||
from ww_core.domain.project_repo import ProjectCreate, ProjectView
|
||||
from ww_core.domain.repositories import DigestView
|
||||
from ww_core.domain.repositories import DigestView, OutlineView
|
||||
from ww_core.domain.review_repo import ReviewView
|
||||
from ww_llm_gateway.types import Delta, LlmRequest, LlmResponse, ServedBy, Usage
|
||||
|
||||
@@ -312,6 +320,40 @@ class FakeOutlineWriteRepo:
|
||||
)
|
||||
|
||||
|
||||
class FakeOutlineReadRepo:
|
||||
"""实现读侧 `OutlineRepo` Protocol(内存;DB beats 形 `{"beats": [...]}`)。
|
||||
|
||||
`add_chapter` 以裸 `list[str]` 便利入种,内部包成 dict(与 DB JSONB round-trip 一致)。
|
||||
`list_for_project` 按 chapter_no 升序返回(仿 SqlOutlineRepo 的 order_by)。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rows: dict[tuple[uuid.UUID, int], OutlineView] = {}
|
||||
|
||||
def add_chapter(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
volume: int,
|
||||
chapter_no: int,
|
||||
beats: list[str],
|
||||
foreshadow_windows: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self.rows[(project_id, chapter_no)] = OutlineView(
|
||||
volume=volume,
|
||||
chapter_no=chapter_no,
|
||||
beats={"beats": list(beats)},
|
||||
foreshadow_windows=[dict(w) for w in (foreshadow_windows or [])],
|
||||
)
|
||||
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return self.rows.get((project_id, chapter_no))
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
views = [v for (p, _), v in self.rows.items() if p == project_id]
|
||||
return sorted(views, key=lambda v: v.chapter_no)
|
||||
|
||||
|
||||
class FakeSessionFactory:
|
||||
"""到期扫描的独立 session 工厂替身:`()` → async-CM 产 `FakeSession`。
|
||||
|
||||
@@ -332,20 +374,106 @@ class FakeSessionFactory:
|
||||
return _cm()
|
||||
|
||||
|
||||
class FakeStyleWriteRepo:
|
||||
"""实现 `StyleFingerprintWriteRepo` Protocol(内存版本化 append + 最新读取)。
|
||||
|
||||
`append` 模拟 version=当前 max+1(首次 1);`latest` 取最大 version 行。
|
||||
供 `GET /style` 读侧 + 学文风 work(mode=update → 第二次 append version+1)断言。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# version -> (dimensions, evidence)
|
||||
self.rows: dict[int, tuple[dict[str, Any], dict[str, Any]]] = {}
|
||||
self.append_calls = 0
|
||||
|
||||
async def append(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
dimensions_json: dict[str, Any],
|
||||
evidence_json: dict[str, Any],
|
||||
) -> int:
|
||||
self.append_calls += 1
|
||||
next_version = (max(self.rows) if self.rows else 0) + 1
|
||||
self.rows[next_version] = (dict(dimensions_json), dict(evidence_json))
|
||||
return next_version
|
||||
|
||||
async def latest(self, project_id: uuid.UUID) -> StyleFingerprintView | None:
|
||||
if not self.rows:
|
||||
return None
|
||||
version = max(self.rows)
|
||||
dimensions, evidence = self.rows[version]
|
||||
return StyleFingerprintView(dimensions=dimensions, evidence=evidence, version=version)
|
||||
|
||||
|
||||
class FakeJobRepo:
|
||||
"""实现 `JobRepo` Protocol(内存):create/get/状态流转,供 `POST /style` 断言。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rows: dict[uuid.UUID, JobView] = {}
|
||||
|
||||
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
|
||||
view = JobView(id=uuid.uuid4(), kind=kind, status=STATUS_QUEUED, progress=0)
|
||||
self.rows[view.id] = view
|
||||
return view
|
||||
|
||||
async def set_running(self, job_id: uuid.UUID) -> JobView:
|
||||
view = self.rows[job_id].model_copy(update={"status": STATUS_RUNNING})
|
||||
self.rows[job_id] = view
|
||||
return view
|
||||
|
||||
async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView:
|
||||
view = self.rows[job_id].model_copy(update={"progress": max(0, min(100, pct))})
|
||||
self.rows[job_id] = view
|
||||
return view
|
||||
|
||||
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
|
||||
view = self.rows[job_id].model_copy(
|
||||
update={"status": STATUS_DONE, "progress": PROGRESS_COMPLETE, "result": dict(result)}
|
||||
)
|
||||
self.rows[job_id] = view
|
||||
return view
|
||||
|
||||
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
|
||||
view = self.rows[job_id].model_copy(update={"status": STATUS_FAILED, "error": error})
|
||||
self.rows[job_id] = view
|
||||
return view
|
||||
|
||||
async def get(self, job_id: uuid.UUID) -> JobView | None:
|
||||
return self.rows.get(job_id)
|
||||
|
||||
async def reap_zombies(self) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
class FakeReviewGateway:
|
||||
"""实现审稿/digest 节点最小依赖(`run`):吐固定结构化 `parsed`,绝不联网。"""
|
||||
|
||||
def __init__(self, parsed: BaseModel | None = None, error: Exception | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
parsed: BaseModel | None = None,
|
||||
error: Exception | None = None,
|
||||
*,
|
||||
text: str | None = None,
|
||||
) -> None:
|
||||
self._parsed = parsed
|
||||
self._error = error
|
||||
# `text` 覆盖纯文本产出(refiner output_schema=None → 端点消费 resp.text)。
|
||||
self._text = text
|
||||
self.requests: list[LlmRequest] = []
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
self.requests.append(req)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
if self._text is not None:
|
||||
out_text = self._text
|
||||
elif self._parsed is not None:
|
||||
out_text = self._parsed.model_dump_json()
|
||||
else:
|
||||
out_text = "{}"
|
||||
return LlmResponse(
|
||||
text=self._parsed.model_dump_json() if self._parsed else "{}",
|
||||
text=out_text,
|
||||
parsed=self._parsed,
|
||||
usage=Usage(
|
||||
provider="fake",
|
||||
|
||||
Reference in New Issue
Block a user