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,184 @@
"""AnthropicClaude适配器ARCH §4.2/§4.4/§4.6)。
- 能力:原生结构化输出(经 instructor、显式前缀缓存`cache_control` 断点)、思考。
- 网络客户端注入(`AnthropicClient` Protocol`AsyncAnthropic` 即满足)——测试注入替身,
绝不联网;真实 SDK 仅在构造客户端时apps/api需要本模块不硬 import `anthropic`。
- 瞬时故障429/超时/5xx/连接错误)翻译为 `TransientProviderError`,交网关退避/回退。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any, Protocol, cast
import instructor
from pydantic import BaseModel
from ..errors import TransientProviderError
from ..types import LlmRequest
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
if TYPE_CHECKING:
from anthropic import AsyncAnthropic
# 瞬时错误类名(按名匹配,避免硬依赖 anthropic SDK 类型)。
_TRANSIENT_NAMES = frozenset(
{
"RateLimitError",
"APITimeoutError",
"APIConnectionError",
"InternalServerError",
"APIStatusError",
}
)
class AnthropicClient(Protocol):
"""`AsyncAnthropic` 满足此 Protocol仅用到 `messages`)。"""
messages: Any
class StructuredAnthropic(Protocol):
async def create(self, *, response_model: type[BaseModel], **kwargs: Any) -> BaseModel: ...
def _is_transient(exc: Exception) -> bool:
name = type(exc).__name__
if name in _TRANSIENT_NAMES:
return True
status = getattr(exc, "status_code", None)
return isinstance(status, int) and (status == 429 or status >= 500)
def _system_blocks(req: LlmRequest) -> list[dict[str, Any]]:
"""system 块;缓存断点前的稳定块带 `cache_control`§4.6)。"""
out: list[dict[str, Any]] = []
for b in req.system:
block: dict[str, Any] = {"type": "text", "text": b.text}
if b.cache:
block["cache_control"] = {"type": "ephemeral"}
out.append(block)
return out
def _input_text(req: LlmRequest) -> str:
if isinstance(req.input, str):
return req.input
return "\n\n".join(b.text for b in req.input)
def _user_messages(req: LlmRequest) -> list[dict[str, Any]]:
return [{"role": "user", "content": _input_text(req)}]
def _usage_from(usage: Any) -> ProviderUsage:
if usage is None:
return ProviderUsage(input_tokens=0, output_tokens=0)
return ProviderUsage(
input_tokens=int(getattr(usage, "input_tokens", 0) or 0),
output_tokens=int(getattr(usage, "output_tokens", 0) or 0),
cache_read_tokens=int(getattr(usage, "cache_read_input_tokens", 0) or 0),
)
def _text_from(resp: Any) -> str:
parts: list[str] = []
for block in getattr(resp, "content", []) or []:
if getattr(block, "type", None) == "text":
parts.append(getattr(block, "text", "") or "")
return "".join(parts)
_DEFAULT_MAX_TOKENS = 4096
class AnthropicAdapter:
def __init__(
self,
provider: str,
client: AnthropicClient,
*,
structured_client: StructuredAnthropic | None = None,
) -> None:
self.provider = provider
self._client = client
self._structured_client = structured_client
def capabilities(self) -> Capabilities:
return Capabilities(structured_output=True, prefix_cache=True, thinking=True)
def _structured(self) -> StructuredAnthropic:
if self._structured_client is None:
# 注入客户端是 `AnthropicClient` Protocol保留测试替身缝
# 但 instructor.from_anthropic 只重载真实 SDK 的具体类型;
# 本适配器异步 → 转 `AsyncAnthropic` 选中 AsyncInstructor 重载,
# 返回的 AsyncInstructor 鸭子匹配 StructuredAnthropic。
async_client = cast("AsyncAnthropic", self._client)
self._structured_client = cast(
"StructuredAnthropic", instructor.from_anthropic(async_client)
)
return self._structured_client
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
try:
if req.output_schema is not None:
return await self._complete_structured(req, model)
return await self._complete_text(req, model)
except Exception as exc:
if _is_transient(exc):
raise TransientProviderError(str(exc), provider=self.provider) from exc
raise
async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult:
kwargs: dict[str, Any] = {
"model": model,
"max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS,
"messages": _user_messages(req),
}
system = _system_blocks(req)
if system:
kwargs["system"] = system
resp = await self._client.messages.create(**kwargs)
usage = _usage_from(getattr(resp, "usage", None))
return ProviderResult(text=_text_from(resp), usage=usage)
async def _complete_structured(self, req: LlmRequest, model: str) -> ProviderResult:
assert req.output_schema is not None
kwargs: dict[str, Any] = {
"model": model,
"max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS,
"messages": _user_messages(req),
}
system = _system_blocks(req)
if system:
kwargs["system"] = system
parsed = await self._structured().create(response_model=req.output_schema, **kwargs)
# instructor.from_anthropic 默认不回 raw usage按名隐藏usage 经流/text 路径覆盖。
usage = ProviderUsage(input_tokens=0, output_tokens=0)
return ProviderResult(text=parsed.model_dump_json(), usage=usage, parsed=parsed)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
kwargs: dict[str, Any] = {
"model": model,
"max_tokens": req.max_tokens or _DEFAULT_MAX_TOKENS,
"messages": _user_messages(req),
}
system = _system_blocks(req)
if system:
kwargs["system"] = system
try:
async with self._client.messages.stream(**kwargs) as stream:
async for event in stream:
if getattr(event, "type", None) == "content_block_delta":
delta = getattr(event, "delta", None)
text = getattr(delta, "text", "") if delta is not None else ""
if text:
yield StreamChunk(text=text)
usage = getattr(event, "usage", None)
if usage is not None:
yield StreamChunk(usage=_usage_from(usage))
except Exception as exc:
if _is_transient(exc):
raise TransientProviderError(str(exc), provider=self.provider) from exc
raise