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:
@@ -11,40 +11,58 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
from openai import AsyncOpenAI
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
|
||||
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
|
||||
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
||||
from ww_core.domain.job_repo import JobRepo, SqlJobRepo
|
||||
from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo
|
||||
from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo
|
||||
from ww_core.domain.repositories import MemoryRepos
|
||||
from ww_core.domain.repositories import MemoryRepos, OutlineRepo, RulesRepo
|
||||
from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo
|
||||
from ww_core.memory.sql_repositories import sql_memory_repos
|
||||
from ww_core.domain.rule_repo import RuleWriteRepo, SqlRuleWriteRepo
|
||||
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo, StyleFingerprintWriteRepo
|
||||
from ww_core.domain.world_entity_repo import SqlWorldEntityWriteRepo, WorldEntityWriteRepo
|
||||
from ww_core.memory.sql_repositories import SqlOutlineRepo, SqlRulesRepo, sql_memory_repos
|
||||
from ww_db import get_session, get_sessionmaker
|
||||
from ww_db.models import User
|
||||
from ww_llm_gateway import (
|
||||
Gateway,
|
||||
OpenAICompatAdapter,
|
||||
ProviderAdapter,
|
||||
Route,
|
||||
SqlAlchemyLedgerSink,
|
||||
build_adapter,
|
||||
chain_from_routing,
|
||||
resolve_route,
|
||||
)
|
||||
from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_PROVIDER
|
||||
from ww_llm_gateway.types import Tier
|
||||
from ww_shared import AppError, ErrorCode
|
||||
from ww_skills import SkillRegistry, SqlSkillRepo
|
||||
|
||||
from ww_api.security.credentials import (
|
||||
CredentialKeyError,
|
||||
decrypt_api_key,
|
||||
)
|
||||
from ww_api.services.credentials import (
|
||||
AUTH_TYPE_OAUTH,
|
||||
STUB_OWNER_ID,
|
||||
CredentialStore,
|
||||
SqlCredentialStore,
|
||||
StoredCredential,
|
||||
)
|
||||
from ww_api.services.foreshadow_scan import SessionFactory
|
||||
from ww_api.services.kimi_oauth import (
|
||||
decrypt_oauth_bundle,
|
||||
encrypt_oauth_bundle,
|
||||
needs_refresh,
|
||||
)
|
||||
from ww_api.services.kimi_oauth import refresh as kimi_refresh
|
||||
from ww_api.services.provider_deps import _PROVIDER_BASE_URLS
|
||||
|
||||
# 单用户 stub 的占位邮箱(多租户化时由真实主体替换)。
|
||||
@@ -105,6 +123,16 @@ def get_foreshadow_repo(
|
||||
return SqlForeshadowLedgerRepo(session)
|
||||
|
||||
|
||||
def get_job_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> JobRepo:
|
||||
"""长任务写侧 repo(创建/进度/完成/失败;状态写只 flush,提交归 run_job/端点)。
|
||||
|
||||
测试经 `app.dependency_overrides[get_job_repo]` 注入 fake(避免真连 DB)。
|
||||
"""
|
||||
return SqlJobRepo(session)
|
||||
|
||||
|
||||
def get_outline_write_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> OutlineWriteRepo:
|
||||
@@ -112,6 +140,90 @@ def get_outline_write_repo(
|
||||
return SqlOutlineWriteRepo(session)
|
||||
|
||||
|
||||
def get_rule_write_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> RuleWriteRepo:
|
||||
"""规则写侧 repo(POST /rules:作者显式加规则;只 flush,端点提交)。测试经 override 注。"""
|
||||
return SqlRuleWriteRepo(session)
|
||||
|
||||
|
||||
async def get_skill_registry(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> SkillRegistry:
|
||||
"""从 `skills` 表加载声明式 skill registry(ARCH §5.6;越权声明 → VALIDATION)。
|
||||
|
||||
每请求按 session 加载(registry 不可变快照)。测试经 `app.dependency_overrides` 注 fake repo
|
||||
或直接注 `SkillRegistry`。技能库 UI(T5.6)经此读 builtin/custom/community。
|
||||
"""
|
||||
return await SkillRegistry.load(SqlSkillRepo(session))
|
||||
|
||||
|
||||
def get_style_write_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> StyleFingerprintWriteRepo:
|
||||
"""文风指纹写侧 repo(`GET /style` 读最新 + 学文风后台任务 append 版本化指纹)。
|
||||
|
||||
注:学文风的 `work` 在 `run_job` 自建的独立 session 上自造 repo(请求 session 已关闭),
|
||||
故本依赖只服务于 `GET /style` 读侧。测试经 `app.dependency_overrides` 注 fake。
|
||||
"""
|
||||
return SqlStyleFingerprintWriteRepo(session)
|
||||
|
||||
|
||||
def get_character_write_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> CharacterWriteRepo:
|
||||
"""角色写侧 repo(POST /characters 入库:schema→DB 形变;只 flush,端点提交)。
|
||||
|
||||
测试经 `app.dependency_overrides` 注 fake。
|
||||
"""
|
||||
return SqlCharacterWriteRepo(session)
|
||||
|
||||
|
||||
def get_world_entity_write_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> WorldEntityWriteRepo:
|
||||
"""世界观实体写侧 repo(预留对称入库;当前生成端点只用其形变能力)。"""
|
||||
return SqlWorldEntityWriteRepo(session)
|
||||
|
||||
|
||||
def get_rules_read_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> RulesRepo:
|
||||
"""规则读侧 repo(GET /rules 列表,复用 C5 assemble 读侧;测试经 override 注 fake)。"""
|
||||
return SqlRulesRepo(session)
|
||||
|
||||
|
||||
def get_outline_read_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> OutlineRepo:
|
||||
"""大纲读侧 repo(GET /outline 列表,复用 C5 assemble 读侧;测试经 override 注 fake)。"""
|
||||
return SqlOutlineRepo(session)
|
||||
|
||||
|
||||
async def get_worldbuilder_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""世界观生成(writer 档位)的可注入网关缝。测试经 override 注 mock(产 WorldGenResult)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "writer")
|
||||
|
||||
|
||||
async def get_character_gen_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""角色生成(writer 档位)的可注入网关缝。测试经 override 注 mock(产 CharacterGenResult)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "writer")
|
||||
|
||||
|
||||
async def get_precheck_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""入库前 continuity 预检(analyst 档位)的可注入网关缝。测试注 mock(产 ContinuityReview)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "analyst")
|
||||
|
||||
|
||||
def get_session_factory() -> SessionFactory:
|
||||
"""验收后到期扫描的**独立 session 工厂**缝。
|
||||
|
||||
@@ -122,42 +234,125 @@ def get_session_factory() -> SessionFactory:
|
||||
return get_sessionmaker()
|
||||
|
||||
|
||||
async def build_gateway_for_tier(
|
||||
session: AsyncSession, store: CredentialStore, tier: Tier
|
||||
) -> Gateway:
|
||||
"""据指定档位路由解密对应 provider 凭据 → 建网关(解析器仍为全局 `resolve_route`)。
|
||||
async def _build_provider_adapter(store: CredentialStore, provider: str) -> ProviderAdapter | None:
|
||||
"""据 provider 解密凭据 → 经 `build_adapter` 工厂建对应 provider 适配器(T5.4 follow-up)。
|
||||
|
||||
无凭据/未知 provider → `LLM_UNAVAILABLE`(友好提示,前端引导去配置)。
|
||||
解析器用 `resolve_route`(按 tier 路由);这里只决定**要预备哪个 provider 的适配器**。
|
||||
`build_adapter(provider, *, api_key, base_url=None)` 按 provider 选适配器类:
|
||||
OpenAI 兼容(deepseek/kimi/qwen/glm/openai)经 `base_url` 走 OpenAI 兼容适配器;
|
||||
Anthropic/Gemini 走各自原生适配器(无需 `base_url`);`kimi-code` 走 OAuth bearer +
|
||||
coding base + 伪造头(**access token 经 `_resolve_kimi_code_token` 按需刷新**,K1.3)。
|
||||
|
||||
返回 `None` 表示该 provider 未配置凭据——回退链里缺位时网关会跳到下一个,故宽容返回
|
||||
None(不直接抛)。
|
||||
"""
|
||||
settings = get_settings()
|
||||
route = resolve_route(tier)
|
||||
base_url = _PROVIDER_BASE_URLS.get(route.provider)
|
||||
if base_url is None:
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
f"{tier} 档位 provider {route.provider} 暂不支持",
|
||||
{"provider": route.provider, "tier": tier},
|
||||
)
|
||||
cred = await store.get_credential(STUB_OWNER_ID, route.provider)
|
||||
cred = await store.get_credential(STUB_OWNER_ID, provider)
|
||||
if cred is None:
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
f"{tier} 档位 provider {route.provider} 未配置凭据,请先在设置中配置",
|
||||
{"provider": route.provider, "tier": tier},
|
||||
return None
|
||||
settings = get_settings()
|
||||
|
||||
if cred.auth_type == AUTH_TYPE_OAUTH or provider == KIMI_CODE_PROVIDER:
|
||||
# OAuth 凭据(Kimi Code):解密 token 包 → 临近过期则刷新并持久化 → access token
|
||||
# 当 api_key 喂工厂(工厂为 kimi-code 构建带伪造头 + coding base 的客户端)。
|
||||
access_token = await _resolve_kimi_code_token(store, cred, settings.credential_enc_key)
|
||||
return build_adapter(
|
||||
provider, api_key=access_token, base_url=_PROVIDER_BASE_URLS.get(provider)
|
||||
)
|
||||
|
||||
if cred.api_key_enc is None:
|
||||
# api_key 凭据但密文缺失(数据不一致)——视作未配置,回退链跳过。
|
||||
return None
|
||||
try:
|
||||
api_key = decrypt_api_key(cred.api_key_enc, key=settings.credential_enc_key)
|
||||
except CredentialKeyError as exc:
|
||||
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
|
||||
# OpenAI 兼容 provider 需 base_url;Anthropic/Gemini 走原生 SDK(base_url=None)。
|
||||
base_url = _PROVIDER_BASE_URLS.get(provider)
|
||||
return build_adapter(provider, api_key=api_key, base_url=base_url)
|
||||
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
adapter = OpenAICompatAdapter(provider=route.provider, client=client)
|
||||
return Gateway(
|
||||
adapters={route.provider: adapter},
|
||||
ledger=SqlAlchemyLedgerSink(session),
|
||||
resolver=resolve_route,
|
||||
)
|
||||
|
||||
async def _resolve_kimi_code_token(
|
||||
store: CredentialStore, cred: StoredCredential, enc_key: str
|
||||
) -> str:
|
||||
"""解密 Kimi Code OAuth token 包 → 临近过期时刷新并持久化 → 返回当前 access token。
|
||||
|
||||
刷新经一个**临时 httpx 客户端**(与 token 端点交互);新 token 包经
|
||||
`store.upsert_oauth_credential` 持久化(下次建网关复用刷新结果)。明文 token 绝不进
|
||||
日志/响应。无 `oauth_enc` → `LLM_UNAVAILABLE`(未连接 Kimi Code)。
|
||||
"""
|
||||
if cred.oauth_enc is None:
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
f"{KIMI_CODE_PROVIDER} 未连接(无 OAuth 凭据),请先在设置中连接 Kimi Code",
|
||||
{"provider": KIMI_CODE_PROVIDER},
|
||||
)
|
||||
try:
|
||||
token = decrypt_oauth_bundle(cred.oauth_enc, key=enc_key)
|
||||
except CredentialKeyError as exc:
|
||||
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
|
||||
|
||||
if not needs_refresh(token):
|
||||
return token.access_token
|
||||
|
||||
# 临近过期 → 刷新并持久化新包。
|
||||
async with httpx.AsyncClient() as http:
|
||||
refreshed = await kimi_refresh(http, token.refresh_token)
|
||||
new_blob = encrypt_oauth_bundle(refreshed, key=enc_key)
|
||||
await store.upsert_oauth_credential(STUB_OWNER_ID, KIMI_CODE_PROVIDER, new_blob)
|
||||
return refreshed.access_token
|
||||
|
||||
|
||||
async def build_gateway_for_tier(
|
||||
session: AsyncSession, store: CredentialStore, tier: Tier
|
||||
) -> Gateway:
|
||||
"""据指定档位路由 + DB `tier_routing.fallback` 装配**多 provider 回退链**网关(T5.4 接线)。
|
||||
|
||||
流程(§4.3 三级解析 / §4.5 回退链):
|
||||
1. 读 DB `tier_routing` 取该 tier 的 primary `provider:model` + fallback 列表(缺则退回
|
||||
全局 `resolve_route`,单 provider,向后兼容)。
|
||||
2. 为 primary + fallback 里**每个能建出适配器**的 provider 预备 OpenAI 兼容适配器
|
||||
(未知 base_url / 未配凭据的 provider 跳过——回退链自然绕过它)。
|
||||
3. 至少要有一个可用适配器,否则 `LLM_UNAVAILABLE`(无任何凭据可用)。
|
||||
4. 注入 `chain_resolver=chain_from_routing(...)`(多元素链,启用回退);无 DB 路由时
|
||||
用 `resolver=resolve_route`(单路由,M1 行为不变)。
|
||||
|
||||
单 provider 配置仍走单元素链(网关把它当无回退处理),**不破既有行为**。
|
||||
"""
|
||||
ledger = SqlAlchemyLedgerSink(session)
|
||||
|
||||
stored = next((r for r in await store.list_routing() if r.tier == tier), None)
|
||||
if stored is None:
|
||||
# 无 DB 路由行:退回全局默认(单 provider,M1 兼容)。
|
||||
route = resolve_route(tier)
|
||||
adapter = await _build_provider_adapter(store, route.provider)
|
||||
if adapter is None:
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
f"{tier} 档位 provider {route.provider} 未配置凭据,请先在设置中配置",
|
||||
{"provider": route.provider, "tier": tier},
|
||||
)
|
||||
return Gateway(adapters={route.provider: adapter}, ledger=ledger, resolver=resolve_route)
|
||||
|
||||
# DB 路由:primary + fallback 构链;为每个可建的 provider 预备适配器。
|
||||
primary_spec = f"{stored.provider}:{stored.model}"
|
||||
chain: list[Route] = chain_from_routing(tier, primary_spec, list(stored.fallback))
|
||||
adapters: dict[str, ProviderAdapter] = {}
|
||||
for route in chain:
|
||||
if route.provider in adapters:
|
||||
continue
|
||||
built = await _build_provider_adapter(store, route.provider)
|
||||
if built is not None:
|
||||
adapters[route.provider] = built
|
||||
if not adapters:
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
f"{tier} 档位无任何已配置凭据的 provider,请先在设置中配置",
|
||||
{"providers": [r.provider for r in chain], "tier": tier},
|
||||
)
|
||||
|
||||
def _resolver(_tier: Tier) -> list[Route]:
|
||||
return chain_from_routing(_tier, primary_spec, list(stored.fallback))
|
||||
|
||||
return Gateway(adapters=adapters, ledger=ledger, chain_resolver=_resolver)
|
||||
|
||||
|
||||
async def build_writer_gateway(session: AsyncSession, store: CredentialStore) -> Gateway:
|
||||
@@ -195,3 +390,25 @@ async def get_outline_gateway(
|
||||
"""大纲生成(analyst 档位)的可注入网关缝。测试经 override 注 mock(产 OutlineResult)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "analyst")
|
||||
|
||||
|
||||
async def get_style_extract_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""学文风提取(analyst 档位)的可注入网关缝。
|
||||
|
||||
`POST /style` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`(503,
|
||||
调度 job 之前拦下,避免凭空写一行注定失败的 job)。提取本体在 BackgroundTask 里
|
||||
用 `run_job` 自建的独立 session 重新构网关跑(请求 session 已关闭);本依赖确保
|
||||
凭据探测在请求阶段发生。测试经 override 注 mock(产 StyleFingerprintResult)。
|
||||
"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "analyst")
|
||||
|
||||
|
||||
async def get_refine_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""回炉(writer 档位)的可注入网关缝。测试经 override 注 mock(产纯文本重写段)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "writer")
|
||||
|
||||
Reference in New Issue
Block a user