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",
|
||||
|
||||
@@ -15,21 +15,34 @@ class FakeCredentialStore:
|
||||
"""实现 `CredentialStore` Protocol 的内存版。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# key: (owner_id, provider) → 密文
|
||||
# key: (owner_id, provider) → api_key 密文
|
||||
self.creds: dict[tuple[uuid.UUID, str], bytes] = {}
|
||||
# key: (owner_id, provider) → oauth 密文(OAuth 凭据,auth_type="oauth")
|
||||
self.oauth: dict[tuple[uuid.UUID, str], bytes] = {}
|
||||
self.routing: dict[str, StoredRouting] = {}
|
||||
|
||||
async def list_credentials(self, owner_id: uuid.UUID) -> list[StoredCredential]:
|
||||
return [
|
||||
rows = [
|
||||
StoredCredential(provider=p, api_key_enc=blob)
|
||||
for (o, p), blob in self.creds.items()
|
||||
if o == owner_id
|
||||
]
|
||||
rows += [
|
||||
StoredCredential(provider=p, api_key_enc=None, auth_type="oauth", oauth_enc=blob)
|
||||
for (o, p), blob in self.oauth.items()
|
||||
if o == owner_id
|
||||
]
|
||||
return rows
|
||||
|
||||
async def list_routing(self) -> list[StoredRouting]:
|
||||
return list(self.routing.values())
|
||||
|
||||
async def get_credential(self, owner_id: uuid.UUID, provider: str) -> StoredCredential | None:
|
||||
oauth_blob = self.oauth.get((owner_id, provider))
|
||||
if oauth_blob is not None:
|
||||
return StoredCredential(
|
||||
provider=provider, api_key_enc=None, auth_type="oauth", oauth_enc=oauth_blob
|
||||
)
|
||||
blob = self.creds.get((owner_id, provider))
|
||||
if blob is None:
|
||||
return None
|
||||
@@ -39,6 +52,19 @@ class FakeCredentialStore:
|
||||
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
|
||||
) -> None:
|
||||
self.creds[(owner_id, provider)] = api_key_enc
|
||||
self.oauth.pop((owner_id, provider), None)
|
||||
|
||||
async def upsert_oauth_credential(
|
||||
self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes
|
||||
) -> None:
|
||||
self.oauth[(owner_id, provider)] = oauth_enc
|
||||
self.creds.pop((owner_id, provider), None)
|
||||
|
||||
async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool:
|
||||
had = (owner_id, provider) in self.creds or (owner_id, provider) in self.oauth
|
||||
self.creds.pop((owner_id, provider), None)
|
||||
self.oauth.pop((owner_id, provider), None)
|
||||
return had
|
||||
|
||||
async def upsert_routing(self, routing: StoredRouting) -> None:
|
||||
self.routing[routing.tier] = routing
|
||||
|
||||
100
apps/api/tests/test_gateway_fallback_wiring.py
Normal file
100
apps/api/tests/test_gateway_fallback_wiring.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""T5.2/T5.4 接线:`build_gateway_for_tier` 据 DB tier_routing 装配多 provider 回退链。
|
||||
|
||||
不联网:只断言网关被装配了**多个适配器** + 注入了回退链解析器(chain_resolver),
|
||||
故链长 > 1。真实回退/降级行为由 packages/llm_gateway 单测 + T5.7 E2E 覆盖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fakes_projects import FakeSession
|
||||
from fakes_providers import FakeCredentialStore
|
||||
from ww_api.services.credentials import STUB_OWNER_ID, StoredRouting
|
||||
|
||||
# 真 Fernet key(测试加解密凭据用;仅本测试,非生产密钥)。
|
||||
_ENC_KEY = "imB6TEcV2AzlpXjR4NjTgK_9Zt-OoqaLFQc3XqBQ0CQ="
|
||||
|
||||
|
||||
def _store_with_creds(*providers: str) -> FakeCredentialStore:
|
||||
from ww_api.security.credentials import encrypt_api_key
|
||||
|
||||
store = FakeCredentialStore()
|
||||
for p in providers:
|
||||
store.creds[(STUB_OWNER_ID, p)] = encrypt_api_key("sk-test", key=_ENC_KEY)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_gateway_wires_fallback_chain_with_multiple_adapters() -> None:
|
||||
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
|
||||
from ww_config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from ww_api.services.project_deps import build_gateway_for_tier
|
||||
|
||||
store = _store_with_creds("deepseek", "kimi")
|
||||
store.routing["writer"] = StoredRouting(
|
||||
tier="writer", provider="deepseek", model="deepseek-chat", fallback=["kimi:moonshot-v1-8k"]
|
||||
)
|
||||
|
||||
gateway = await build_gateway_for_tier(FakeSession(), store, "writer")
|
||||
|
||||
# 两个 provider 的适配器都装配进网关 → 回退链可绕过首个失败者。
|
||||
assert set(gateway._adapters) == {"deepseek", "kimi"} # noqa: SLF001
|
||||
# chain_resolver 注入 → 链长 2(去重保序)。
|
||||
chain = gateway._resolve_chain("writer") # noqa: SLF001
|
||||
assert [r.provider for r in chain] == ["deepseek", "kimi"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_gateway_single_provider_still_works() -> None:
|
||||
"""无 fallback 的路由行:单元素链(M1 行为不变,向后兼容)。"""
|
||||
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
|
||||
from ww_config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from ww_api.services.project_deps import build_gateway_for_tier
|
||||
|
||||
store = _store_with_creds("deepseek")
|
||||
store.routing["analyst"] = StoredRouting(
|
||||
tier="analyst", provider="deepseek", model="deepseek-chat", fallback=[]
|
||||
)
|
||||
|
||||
gateway = await build_gateway_for_tier(FakeSession(), store, "analyst")
|
||||
assert set(gateway._adapters) == {"deepseek"} # noqa: SLF001
|
||||
chain = gateway._resolve_chain("analyst") # noqa: SLF001
|
||||
assert len(chain) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_gateway_no_routing_row_falls_back_to_global_default() -> None:
|
||||
"""无 DB 路由行 → 退回全局 resolve_route(单 provider)。"""
|
||||
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
|
||||
from ww_config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from ww_api.services.project_deps import build_gateway_for_tier
|
||||
|
||||
store = _store_with_creds("deepseek") # 无 routing 行
|
||||
gateway = await build_gateway_for_tier(FakeSession(), store, "writer")
|
||||
assert "deepseek" in gateway._adapters # noqa: SLF001
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_gateway_no_usable_credentials_raises_503() -> None:
|
||||
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
|
||||
from ww_config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from ww_api.services.project_deps import build_gateway_for_tier
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
store = FakeCredentialStore() # 无任何凭据
|
||||
store.routing["writer"] = StoredRouting(
|
||||
tier="writer", provider="deepseek", model="deepseek-chat", fallback=["kimi:moonshot-v1-8k"]
|
||||
)
|
||||
with pytest.raises(AppError) as exc:
|
||||
await build_gateway_for_tier(FakeSession(), store, "writer")
|
||||
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
|
||||
506
apps/api/tests/test_generation.py
Normal file
506
apps/api/tests/test_generation.py
Normal file
@@ -0,0 +1,506 @@
|
||||
"""T5.2 生成/入库 + 读端点测试(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- POST /world/generate:mock 网关产 WorldGenResult → 预览 + commit(落 ledger);项目不存在 404。
|
||||
- POST /characters/generate:mock 网关产 CharacterGenResult → 预览(含已有角色注入防雷同)。
|
||||
- POST /characters(入库):precheck 无冲突 → 写库 + 201;有冲突且未确认 → 409;确认后放行写库。
|
||||
- 无凭据 → 503(override 网关 dep 抛 LLM_UNAVAILABLE)。
|
||||
- GET /rules:规则列表;GET /skills:技能库列表。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fakes_projects import FakeProjectRepo, FakeSession
|
||||
from test_projects import _empty_memory_repos
|
||||
from ww_agents import (
|
||||
AgentSpec,
|
||||
CharacterCard,
|
||||
CharacterGenResult,
|
||||
Conflict,
|
||||
ContinuityReview,
|
||||
WorldEntityCard,
|
||||
WorldGenResult,
|
||||
)
|
||||
from ww_core.domain.character_repo import CharacterWriteView
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_core.domain.repositories import RuleView
|
||||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||
from ww_shared import AppError, ErrorCode
|
||||
from ww_skills import SkillRegistry
|
||||
|
||||
STUB_OWNER = uuid.UUID(int=1)
|
||||
|
||||
|
||||
class _SchemaRoutingGateway:
|
||||
"""按 `req.output_schema` 返对应 parsed(world/character/precheck 各拿自己的产物)。"""
|
||||
|
||||
def __init__(self, by_schema: dict[type[Any] | None, Any]) -> None:
|
||||
self._by_schema = by_schema
|
||||
self.calls: list[type[Any] | None] = []
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
schema = req.output_schema
|
||||
self.calls.append(schema)
|
||||
parsed = self._by_schema.get(schema)
|
||||
return LlmResponse(
|
||||
text=parsed.model_dump_json() if parsed is not None else "{}",
|
||||
parsed=parsed,
|
||||
usage=Usage(
|
||||
provider="fake",
|
||||
model="fake",
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
cost_minor=0,
|
||||
currency="USD",
|
||||
),
|
||||
served_by=ServedBy(provider="fake", model="fake"),
|
||||
)
|
||||
|
||||
|
||||
class _FakeCharacterWriteRepo:
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[dict[str, Any]] = []
|
||||
|
||||
async def create(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
name: str,
|
||||
role: str,
|
||||
traits: list[str],
|
||||
backstory: str,
|
||||
arc: str,
|
||||
speech_tics: list[str],
|
||||
tags: list[Any],
|
||||
relations: list[dict[str, Any]],
|
||||
) -> CharacterWriteView:
|
||||
self.rows.append(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"name": name,
|
||||
"role": role,
|
||||
"traits": list(traits),
|
||||
"arc": arc,
|
||||
"speech_tics": list(speech_tics),
|
||||
"tags": list(tags),
|
||||
"relations": [dict(r) for r in relations],
|
||||
}
|
||||
)
|
||||
return CharacterWriteView(id=uuid.uuid4(), name=name, role=role)
|
||||
|
||||
|
||||
class _FakeRulesReadRepo:
|
||||
def __init__(self, rules: list[RuleView] | None = None) -> None:
|
||||
self._rules = rules or []
|
||||
|
||||
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
|
||||
return list(self._rules)
|
||||
|
||||
|
||||
def _world_result() -> WorldGenResult:
|
||||
return WorldGenResult(
|
||||
entities=[WorldEntityCard(type="力量体系", name="灵脉", rules=["灵力守恒", "禁止跨界"])]
|
||||
)
|
||||
|
||||
|
||||
def _character_result() -> CharacterGenResult:
|
||||
return CharacterGenResult(
|
||||
cards=[
|
||||
CharacterCard(
|
||||
name="叶寒",
|
||||
role="主角",
|
||||
traits=["腹黑", "护短"],
|
||||
backstory="孤儿出身",
|
||||
arc="从孤儿到帝王",
|
||||
speech_tics=["哼"],
|
||||
tags=["天才"],
|
||||
relations=[],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _no_conflicts() -> ContinuityReview:
|
||||
return ContinuityReview(conflicts=[])
|
||||
|
||||
|
||||
def _with_conflicts() -> ContinuityReview:
|
||||
return ContinuityReview(
|
||||
conflicts=[Conflict(type="设定违例", where="叶寒卡", refs=["灵脉"], suggestion="改设定")]
|
||||
)
|
||||
|
||||
|
||||
def _skill_registry() -> SkillRegistry:
|
||||
spec = AgentSpec(
|
||||
name="custom-namer",
|
||||
tier="light",
|
||||
system_prompt="取名",
|
||||
input_schema=None,
|
||||
output_schema=None,
|
||||
reads=["characters"],
|
||||
writes=["characters"],
|
||||
scope="custom",
|
||||
)
|
||||
return SkillRegistry({spec.name: spec})
|
||||
|
||||
|
||||
def _make_app(
|
||||
*,
|
||||
project_repo: FakeProjectRepo,
|
||||
gateway: Any,
|
||||
char_repo: _FakeCharacterWriteRepo | None = None,
|
||||
session: FakeSession | None = None,
|
||||
rules_repo: _FakeRulesReadRepo | None = None,
|
||||
registry: SkillRegistry | None = None,
|
||||
memory: Any = None,
|
||||
no_creds: bool = False,
|
||||
) -> tuple[Any, FakeSession]:
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||
from ww_api.main import create_app
|
||||
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,
|
||||
)
|
||||
from ww_db import get_session
|
||||
|
||||
session = session or FakeSession()
|
||||
char_repo = char_repo or _FakeCharacterWriteRepo()
|
||||
rules_repo = rules_repo or _FakeRulesReadRepo()
|
||||
registry = registry or _skill_registry()
|
||||
|
||||
async def _raise_no_creds() -> object:
|
||||
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_memory_repos] = (
|
||||
(lambda: memory) if memory is not None else _empty_memory_repos
|
||||
)
|
||||
app.dependency_overrides[get_character_write_repo] = lambda: char_repo
|
||||
app.dependency_overrides[get_rules_read_repo] = lambda: rules_repo
|
||||
app.dependency_overrides[get_skill_registry] = lambda: registry
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
gw = _raise_no_creds if no_creds else (lambda: gateway)
|
||||
app.dependency_overrides[get_worldbuilder_gateway] = gw
|
||||
app.dependency_overrides[get_character_gen_gateway] = gw
|
||||
app.dependency_overrides[get_precheck_gateway] = gw
|
||||
return app, session
|
||||
|
||||
|
||||
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
|
||||
view = await repo.create(STUB_OWNER, ProjectCreate(title="测试作品", genre="玄幻"))
|
||||
return uuid.UUID(str(view.id))
|
||||
|
||||
|
||||
def _client(app: Any) -> httpx.AsyncClient:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
# ---- 世界观生成 ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_world_returns_preview_and_commits_ledger() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({WorldGenResult: _world_result()})
|
||||
app, session = _make_app(project_repo=repo, gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/world/generate", json={"brief": "东方玄幻"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["entities"][0]["name"] == "灵脉"
|
||||
assert body["entities"][0]["rules"] == ["灵力守恒", "禁止跨界"]
|
||||
assert session.commits == 1 # 预览不写业务表,但落 ledger
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_world_unknown_project_404() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
gateway = _SchemaRoutingGateway({WorldGenResult: _world_result()})
|
||||
app, _ = _make_app(project_repo=repo, gateway=gateway)
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{uuid.uuid4()}/world/generate", json={"brief": "x"})
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_world_no_credentials_503() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
app, session = _make_app(project_repo=repo, gateway=object(), no_creds=True)
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/world/generate", json={"brief": "x"})
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
# ---- 角色生成 ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_characters_returns_preview() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({CharacterGenResult: _character_result()})
|
||||
app, session = _make_app(project_repo=repo, gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/characters/generate", json={"brief": "天才主角", "count": 1}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
card = resp.json()["cards"][0]
|
||||
assert card["name"] == "叶寒"
|
||||
assert card["traits"] == ["腹黑", "护短"]
|
||||
assert card["arc"] == "从孤儿到帝王"
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
# ---- 角色入库(gate)----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_characters_no_conflict_writes_and_201() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||||
char_repo = _FakeCharacterWriteRepo()
|
||||
app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/characters",
|
||||
json={
|
||||
"cards": [
|
||||
{
|
||||
"name": "叶寒",
|
||||
"role": "主角",
|
||||
"traits": ["腹黑"],
|
||||
"backstory": "孤儿",
|
||||
"arc": "成长",
|
||||
"speech_tics": ["哼"],
|
||||
"tags": ["天才"],
|
||||
"relations": [],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["created"] == ["叶寒"]
|
||||
assert body["rejected_tables"] == []
|
||||
assert len(char_repo.rows) == 1
|
||||
assert char_repo.rows[0]["traits"] == ["腹黑"]
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_characters_with_conflict_blocks_409() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
|
||||
char_repo = _FakeCharacterWriteRepo()
|
||||
app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/characters",
|
||||
json={"cards": [{"name": "叶寒", "role": "主角", "backstory": "孤儿", "arc": "成长"}]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
err = resp.json()["error"]
|
||||
assert err["code"] == ErrorCode.CONFLICT_UNRESOLVED
|
||||
assert err["details"]["conflict_count"] == 1
|
||||
assert err["details"]["conflicts"][0]["type"] == "设定违例"
|
||||
# 未写库(gate 拦下),但落 precheck ledger。
|
||||
assert len(char_repo.rows) == 0
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_characters_acknowledged_conflict_writes() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
|
||||
char_repo = _FakeCharacterWriteRepo()
|
||||
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/characters",
|
||||
json={
|
||||
"cards": [{"name": "叶寒", "role": "主角", "backstory": "孤儿", "arc": "成长"}],
|
||||
"acknowledge_conflicts": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert len(char_repo.rows) == 1
|
||||
|
||||
|
||||
# ---- 读端点 ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_rules_returns_rules() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
rules_repo = _FakeRulesReadRepo(
|
||||
[
|
||||
RuleView(level="project", content="主角不复活"),
|
||||
RuleView(level="global", content="无脏话"),
|
||||
]
|
||||
)
|
||||
gateway = _SchemaRoutingGateway({})
|
||||
app, _ = _make_app(project_repo=repo, gateway=gateway, rules_repo=rules_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.get(f"/projects/{pid}/rules")
|
||||
|
||||
assert resp.status_code == 200
|
||||
rules = resp.json()["rules"]
|
||||
assert [r["content"] for r in rules] == ["主角不复活", "无脏话"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_returns_registry() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
gateway = _SchemaRoutingGateway({})
|
||||
app, _ = _make_app(project_repo=repo, gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.get("/skills")
|
||||
|
||||
assert resp.status_code == 200
|
||||
skills = resp.json()["skills"]
|
||||
assert skills[0]["name"] == "custom-namer"
|
||||
assert skills[0]["scope"] == "custom"
|
||||
assert skills[0]["writes"] == ["characters"]
|
||||
|
||||
|
||||
# ---- 设定库 Codex 读端点(GET characters / world_entities)----
|
||||
|
||||
|
||||
def _codex_memory() -> Any:
|
||||
"""MemoryRepos:角色/世界观各一行(DB JSONB dict 形,验反向解包到 API list/str)。"""
|
||||
from test_projects import (
|
||||
_EmptyDigestRepo,
|
||||
_EmptyForeshadowRepo,
|
||||
_EmptyOutlineRepo,
|
||||
_EmptyRulesRepo,
|
||||
_EmptyStyleRepo,
|
||||
_StubProjectSpecRepo,
|
||||
)
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
MemoryRepos,
|
||||
WorldEntityView,
|
||||
)
|
||||
|
||||
class _CharRepo:
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
|
||||
return [
|
||||
CharacterView(
|
||||
name="叶寒",
|
||||
role="主角",
|
||||
traits={"items": ["腹黑", "护短"]},
|
||||
backstory="孤儿出身",
|
||||
arc={"text": "从孤儿到帝王"},
|
||||
speech_tics={"items": ["哼"]},
|
||||
tags=["天才"],
|
||||
relations=[],
|
||||
)
|
||||
]
|
||||
|
||||
class _WorldRepo:
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
|
||||
return [
|
||||
WorldEntityView(
|
||||
type="力量体系",
|
||||
name="灵脉",
|
||||
rules={"rules": ["灵力守恒", "禁止跨界"]},
|
||||
)
|
||||
]
|
||||
|
||||
return MemoryRepos(
|
||||
outline=_EmptyOutlineRepo(),
|
||||
character=_CharRepo(),
|
||||
world_entity=_WorldRepo(),
|
||||
digest=_EmptyDigestRepo(),
|
||||
foreshadow=_EmptyForeshadowRepo(),
|
||||
style=_EmptyStyleRepo(),
|
||||
rules=_EmptyRulesRepo(),
|
||||
project=_StubProjectSpecRepo(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}), memory=_codex_memory())
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.get(f"/projects/{pid}/characters")
|
||||
|
||||
assert resp.status_code == 200
|
||||
chars = resp.json()["characters"]
|
||||
assert len(chars) == 1
|
||||
card = chars[0]
|
||||
# DB JSONB dict → API list/str(反向解包,入库形变的逆向)。
|
||||
assert card["name"] == "叶寒"
|
||||
assert card["traits"] == ["腹黑", "护短"]
|
||||
assert card["speech_tics"] == ["哼"]
|
||||
assert card["arc"] == "从孤儿到帝王"
|
||||
assert card["tags"] == ["天才"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_world_entities_unpacks_rules_to_list() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}), memory=_codex_memory())
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.get(f"/projects/{pid}/world_entities")
|
||||
|
||||
assert resp.status_code == 200
|
||||
entities = resp.json()["world_entities"]
|
||||
assert len(entities) == 1
|
||||
assert entities[0]["type"] == "力量体系"
|
||||
assert entities[0]["name"] == "灵脉"
|
||||
assert entities[0]["rules"] == ["灵力守恒", "禁止跨界"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_characters_empty_when_none_ingested() -> None:
|
||||
repo = FakeProjectRepo()
|
||||
pid = await _seed_project(repo)
|
||||
app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.get(f"/projects/{pid}/characters")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["characters"] == []
|
||||
153
apps/api/tests/test_job_runner.py
Normal file
153
apps/api/tests/test_job_runner.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""T4.1 通用长任务 runner 单测(ARCH §7.4)。
|
||||
|
||||
直接 `await run_job(...)`(不起后台线程、不连真 DB),注入 fake session 工厂 + fake job
|
||||
repo + fake work,断言:
|
||||
- 成功路:set_running → work → complete(result) → commit;
|
||||
- 失败路:work 抛 → 回滚 → 新 session 里 fail(error) → commit,且异常不冒泡。
|
||||
|
||||
独立 session 纪律 = `run_job` 自建 session(经 session_factory),与 `run_overdue_scan`
|
||||
先例一致。这里的 fake session 工厂记录开了几次 session + 各次 commit。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_api.services.job_runner import run_job
|
||||
from ww_core.domain.job_repo import (
|
||||
PROGRESS_COMPLETE,
|
||||
STATUS_DONE,
|
||||
STATUS_FAILED,
|
||||
STATUS_RUNNING,
|
||||
JobView,
|
||||
)
|
||||
|
||||
JOB_ID = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""最小 fake:记录 commit 次数(提交边界断言)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.commits = 0
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
|
||||
class _FakeSessionFactory:
|
||||
"""独立 session 工厂替身:`()` → async-CM 产新 `_FakeSession`,记录开了几次。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sessions: list[_FakeSession] = []
|
||||
|
||||
def __call__(self) -> Any:
|
||||
session = _FakeSession()
|
||||
self.sessions.append(session)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _cm() -> AsyncIterator[_FakeSession]:
|
||||
yield session
|
||||
|
||||
return _cm()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeJobRepo:
|
||||
"""内存 job repo:记录状态流转 + result/error(不触 DB)。"""
|
||||
|
||||
status: str = "queued"
|
||||
progress: int = 0
|
||||
result: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
calls: list[str] = field(default_factory=list)
|
||||
|
||||
def _view(self, job_id: uuid.UUID) -> JobView:
|
||||
return JobView(
|
||||
id=job_id,
|
||||
kind="style_learn",
|
||||
status=self.status,
|
||||
progress=self.progress,
|
||||
result=self.result,
|
||||
error=self.error,
|
||||
)
|
||||
|
||||
async def set_running(self, job_id: uuid.UUID) -> JobView:
|
||||
self.calls.append("set_running")
|
||||
self.status = STATUS_RUNNING
|
||||
return self._view(job_id)
|
||||
|
||||
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
|
||||
self.calls.append("complete")
|
||||
self.status = STATUS_DONE
|
||||
self.progress = PROGRESS_COMPLETE
|
||||
self.result = dict(result)
|
||||
return self._view(job_id)
|
||||
|
||||
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
|
||||
self.calls.append("fail")
|
||||
self.status = STATUS_FAILED
|
||||
self.error = error
|
||||
return self._view(job_id)
|
||||
|
||||
|
||||
# ---- success path ----
|
||||
|
||||
|
||||
async def test_run_job_success_sets_done_and_commits() -> None:
|
||||
factory = _FakeSessionFactory()
|
||||
repo = _FakeJobRepo()
|
||||
received: list[AsyncSession] = []
|
||||
|
||||
async def work(session: AsyncSession) -> dict[str, Any]:
|
||||
received.append(session)
|
||||
return {"version": 1, "dims_count": 16}
|
||||
|
||||
await run_job(
|
||||
factory,
|
||||
JOB_ID,
|
||||
work,
|
||||
repo_factory=lambda _s: repo,
|
||||
)
|
||||
|
||||
assert repo.calls == ["set_running", "complete"]
|
||||
assert repo.status == STATUS_DONE
|
||||
assert repo.progress == PROGRESS_COMPLETE
|
||||
assert repo.result == {"version": 1, "dims_count": 16}
|
||||
# 自建了一个独立 session 且提交了一次
|
||||
assert len(factory.sessions) == 1
|
||||
assert factory.sessions[0].commits == 1
|
||||
# work 拿到的就是 run_job 自建的 session
|
||||
assert len(received) == 1
|
||||
|
||||
|
||||
# ---- failure path ----
|
||||
|
||||
|
||||
async def test_run_job_failure_sets_failed_and_does_not_raise() -> None:
|
||||
factory = _FakeSessionFactory()
|
||||
repo = _FakeJobRepo()
|
||||
|
||||
async def work(_session: AsyncSession) -> dict[str, Any]:
|
||||
raise RuntimeError("extraction blew up")
|
||||
|
||||
# 异常被吞(后台任务边界),不冒泡
|
||||
await run_job(
|
||||
factory,
|
||||
JOB_ID,
|
||||
work,
|
||||
repo_factory=lambda _s: repo,
|
||||
)
|
||||
|
||||
assert "complete" not in repo.calls
|
||||
assert "fail" in repo.calls
|
||||
assert repo.status == STATUS_FAILED
|
||||
assert repo.error == "extraction blew up"
|
||||
# 失败置态在一个**全新** session 里完成并 commit(原事务作废)
|
||||
assert len(factory.sessions) == 2
|
||||
assert factory.sessions[1].commits == 1
|
||||
77
apps/api/tests/test_kimi_code_key_wiring.py
Normal file
77
apps/api/tests/test_kimi_code_key_wiring.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""接线测试:`kimi-code-key` 作为**普通 api_key** provider 经既有路径建干净 coding 适配器。
|
||||
|
||||
不联网。与 OAuth 的 `kimi-code` 对照——`kimi-code-key`:
|
||||
- 走 api_key 凭据路径(Fernet `api_key_enc`),**不**触发 OAuth 刷新分支;
|
||||
- `build_gateway_for_tier` 据 DB `tier_routing` 路由 → `_build_provider_adapter` →
|
||||
`build_adapter("kimi-code-key", ...)` → `KimiCodeKeyAdapter`(coding base + 纯 bearer);
|
||||
- 适配器**不带**伪造 UA / X-Msh-* 头,结构化走 JSON 模式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fakes_projects import FakeSession
|
||||
from fakes_providers import FakeCredentialStore
|
||||
from ww_api.services.credentials import STUB_OWNER_ID, StoredRouting
|
||||
from ww_api.services.project_deps import _build_provider_adapter, build_gateway_for_tier
|
||||
from ww_llm_gateway.adapters.kimi_code_key import KIMI_CODE_KEY_PROVIDER, KimiCodeKeyAdapter
|
||||
|
||||
_ENC_KEY = "imB6TEcV2AzlpXjR4NjTgK_9Zt-OoqaLFQc3XqBQ0CQ="
|
||||
_API_KEY = "kimi-console-static-key-not-real"
|
||||
|
||||
|
||||
def _set_env() -> None:
|
||||
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
|
||||
from ww_config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _store_with_key() -> FakeCredentialStore:
|
||||
from ww_api.security.credentials import encrypt_api_key
|
||||
|
||||
store = FakeCredentialStore()
|
||||
store.creds[(STUB_OWNER_ID, KIMI_CODE_KEY_PROVIDER)] = encrypt_api_key(_API_KEY, key=_ENC_KEY)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_provider_adapter_for_kimi_code_key_sends_spoofed_headers() -> None:
|
||||
_set_env()
|
||||
store = _store_with_key()
|
||||
|
||||
adapter = await _build_provider_adapter(store, KIMI_CODE_KEY_PROVIDER)
|
||||
|
||||
assert isinstance(adapter, KimiCodeKeyAdapter)
|
||||
assert adapter.provider == "kimi-code-key"
|
||||
client = adapter._client # noqa: SLF001
|
||||
assert str(client.base_url).rstrip("/") == "https://api.kimi.com/coding/v1"
|
||||
assert client.api_key == _API_KEY
|
||||
# 实测:coding 端点据 UA 做 allow-list 门禁,缺伪造头→403,故静态 key 也必须发伪造头。
|
||||
headers = client.default_headers
|
||||
assert any(str(k).lower().startswith("x-msh-") for k in headers)
|
||||
assert "KimiCLI" in str(headers.get("User-Agent", ""))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_gateway_for_tier_routes_kimi_code_key_for_coding_model() -> None:
|
||||
_set_env()
|
||||
store = _store_with_key()
|
||||
# 把 writer 档位路由到 kimi-code-key:kimi-for-coding(无回退)。
|
||||
store.routing["writer"] = StoredRouting(
|
||||
tier="writer",
|
||||
provider=KIMI_CODE_KEY_PROVIDER,
|
||||
model="kimi-for-coding",
|
||||
fallback=[],
|
||||
)
|
||||
|
||||
gateway = await build_gateway_for_tier(FakeSession(), store, "writer")
|
||||
|
||||
assert set(gateway._adapters) == {KIMI_CODE_KEY_PROVIDER} # noqa: SLF001
|
||||
adapter = gateway._adapters[KIMI_CODE_KEY_PROVIDER] # noqa: SLF001
|
||||
assert isinstance(adapter, KimiCodeKeyAdapter)
|
||||
chain = gateway._resolve_chain("writer") # noqa: SLF001
|
||||
assert [r.provider for r in chain] == [KIMI_CODE_KEY_PROVIDER]
|
||||
assert chain[0].model == "kimi-for-coding"
|
||||
125
apps/api/tests/test_kimi_code_wiring.py
Normal file
125
apps/api/tests/test_kimi_code_wiring.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""K1.3 接线测试:`_build_provider_adapter` 为 kimi-code 走 OAuth 路径 + 按需刷新。
|
||||
|
||||
不联网:refresh 路径 monkeypatch httpx + kimi_oauth.refresh。断言:
|
||||
- 凭据未临近过期 → 直接用现有 access token 建 KimiCodeAdapter(不刷新);
|
||||
- 临近过期 → 调 refresh + 持久化新包 + 用新 access token 建适配器;
|
||||
- 无 oauth 凭据 → LLM_UNAVAILABLE。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fakes_providers import FakeCredentialStore
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.kimi_oauth import TokenSet, decrypt_oauth_bundle, encrypt_oauth_bundle
|
||||
from ww_llm_gateway.adapters.kimi_code import KIMI_CODE_PROVIDER, KimiCodeAdapter
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
_ENC_KEY = "imB6TEcV2AzlpXjR4NjTgK_9Zt-OoqaLFQc3XqBQ0CQ="
|
||||
|
||||
|
||||
def _store_with_token(token: TokenSet) -> FakeCredentialStore:
|
||||
store = FakeCredentialStore()
|
||||
store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)] = encrypt_oauth_bundle(token, key=_ENC_KEY)
|
||||
return store
|
||||
|
||||
|
||||
def _set_env() -> None:
|
||||
os.environ["CREDENTIAL_ENC_KEY"] = _ENC_KEY
|
||||
from ww_config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_adapter_uses_existing_token_when_fresh() -> None:
|
||||
_set_env()
|
||||
from ww_api.services.project_deps import _build_provider_adapter
|
||||
|
||||
fresh = TokenSet(
|
||||
access_token="fresh-acc",
|
||||
refresh_token="ref",
|
||||
expires_at=datetime.now(UTC) + timedelta(seconds=900),
|
||||
)
|
||||
store = _store_with_token(fresh)
|
||||
|
||||
adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER)
|
||||
|
||||
assert isinstance(adapter, KimiCodeAdapter)
|
||||
# 未刷新:oauth 凭据未变。
|
||||
cred = store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)]
|
||||
assert decrypt_oauth_bundle(cred, key=_ENC_KEY).access_token == "fresh-acc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_adapter_refreshes_when_near_expiry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_env()
|
||||
from ww_api.services import project_deps
|
||||
from ww_api.services.project_deps import _build_provider_adapter
|
||||
|
||||
near = TokenSet(
|
||||
access_token="old-acc",
|
||||
refresh_token="old-ref",
|
||||
expires_at=datetime.now(UTC) + timedelta(seconds=30), # 临近过期
|
||||
)
|
||||
store = _store_with_token(near)
|
||||
|
||||
refreshed = TokenSet(
|
||||
access_token="new-acc",
|
||||
refresh_token="new-ref",
|
||||
expires_at=datetime.now(UTC) + timedelta(seconds=900),
|
||||
)
|
||||
|
||||
async def _fake_refresh(_http: Any, _refresh_token: str) -> TokenSet:
|
||||
assert _refresh_token == "old-ref"
|
||||
return refreshed
|
||||
|
||||
# 替换 refresh + httpx.AsyncClient(避免真联网)。
|
||||
monkeypatch.setattr(project_deps, "kimi_refresh", _fake_refresh)
|
||||
|
||||
class _FakeAsyncClient:
|
||||
async def __aenter__(self) -> _FakeAsyncClient:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc: object) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"ww_api.services.project_deps.httpx.AsyncClient", lambda *a, **k: _FakeAsyncClient()
|
||||
)
|
||||
|
||||
adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER)
|
||||
|
||||
assert isinstance(adapter, KimiCodeAdapter)
|
||||
# 刷新后新包持久化(下次复用刷新结果)。
|
||||
cred = store.oauth[(STUB_OWNER_ID, KIMI_CODE_PROVIDER)]
|
||||
assert decrypt_oauth_bundle(cred, key=_ENC_KEY).access_token == "new-acc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_adapter_no_credential_returns_none() -> None:
|
||||
_set_env()
|
||||
from ww_api.services.project_deps import _build_provider_adapter
|
||||
|
||||
store = FakeCredentialStore() # 无任何凭据
|
||||
adapter = await _build_provider_adapter(store, KIMI_CODE_PROVIDER)
|
||||
assert adapter is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_adapter_raises_when_oauth_missing_blob() -> None:
|
||||
"""auth_type=oauth 但 oauth_enc=None(数据不一致)→ LLM_UNAVAILABLE。"""
|
||||
_set_env()
|
||||
from ww_api.services.credentials import StoredCredential
|
||||
from ww_api.services.project_deps import _resolve_kimi_code_token
|
||||
|
||||
bad = StoredCredential(
|
||||
provider=KIMI_CODE_PROVIDER, api_key_enc=None, auth_type="oauth", oauth_enc=None
|
||||
)
|
||||
with pytest.raises(AppError) as exc:
|
||||
await _resolve_kimi_code_token(FakeCredentialStore(), bad, _ENC_KEY)
|
||||
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
|
||||
213
apps/api/tests/test_kimi_oauth_endpoints.py
Normal file
213
apps/api/tests/test_kimi_oauth_endpoints.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""K1.3 端点测试:Kimi Code OAuth start/disconnect/status(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- POST .../start → 202 + user_code/verification_uri + 创建 job;后台轮询 poll_token 成功
|
||||
→ 存加密 oauth_enc + job done;**token 不进 job 结果/响应**;
|
||||
- 后台轮询 authorization_pending → 继续轮询直到成功;
|
||||
- POST .../disconnect → 清除凭据行;
|
||||
- GET .../status → connected 真值(无 token 本体)。
|
||||
|
||||
后台 work 用 `run_job` 自建独立 session(请求 session 已关闭)——测试经依赖 override
|
||||
注 fake http + fake store(共享实例,断后台落库)+ FakeSessionFactory;monkeypatch
|
||||
`kimi_oauth.asyncio.sleep` 为 no-op(避免真等 interval 秒)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fakes_providers import FakeCredentialStore
|
||||
from fastapi.testclient import TestClient
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.kimi_oauth import decrypt_oauth_bundle
|
||||
from ww_db import get_session
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int, body: dict[str, Any]) -> None:
|
||||
self.status_code = status_code
|
||||
self._body = body
|
||||
|
||||
def json(self) -> Any:
|
||||
return self._body
|
||||
|
||||
|
||||
class _ScriptedHttp:
|
||||
"""按调用序号返回预设响应(device auth → poll attempts)。aclose 无操作。"""
|
||||
|
||||
def __init__(self, responses: list[_FakeResponse]) -> None:
|
||||
self._responses = responses
|
||||
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||
|
||||
async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse:
|
||||
idx = len(self.calls)
|
||||
self.calls.append((url, data))
|
||||
return self._responses[idx]
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _make_client(
|
||||
*,
|
||||
store: FakeCredentialStore,
|
||||
http: _ScriptedHttp,
|
||||
enc_key: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> TestClient:
|
||||
os.environ["CREDENTIAL_ENC_KEY"] = enc_key
|
||||
from ww_config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
from fakes_projects import FakeJobRepo, FakeSession, FakeSessionFactory
|
||||
from ww_api import routers
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services import job_runner
|
||||
from ww_api.services.project_deps import (
|
||||
get_job_repo,
|
||||
get_session_factory,
|
||||
)
|
||||
from ww_api.services.provider_deps import get_credential_store
|
||||
|
||||
# 后台轮询不真等 interval 秒。
|
||||
async def _no_sleep(_seconds: float) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("ww_api.routers.kimi_oauth.asyncio.sleep", _no_sleep)
|
||||
# 后台 work 自建 SqlCredentialStore(session)——指到共享 fake store(断后台落库)。
|
||||
monkeypatch.setattr(routers.kimi_oauth, "SqlCredentialStore", lambda _session: store)
|
||||
# 后台 work 内调模块级 `_default_http_client()` 自建 http(非 dep)——monkeypatch 它指到
|
||||
# scripted fake,否则后台轮询会真联网(见 device-flow 注入纪律)。
|
||||
monkeypatch.setattr(routers.kimi_oauth, "_default_http_client", lambda: http)
|
||||
# `run_job` 默认 repo_factory 构 `SqlJobRepo(session)`——FakeSession 无 .execute,
|
||||
# 故 monkeypatch 模块级 `job_runner.SqlJobRepo` 指到共享 fake(见 memory/gotchas)。
|
||||
job_repo = FakeJobRepo()
|
||||
monkeypatch.setattr(job_runner, "SqlJobRepo", lambda _session: job_repo)
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_credential_store] = lambda: store
|
||||
app.dependency_overrides[get_job_repo] = lambda: job_repo
|
||||
app.dependency_overrides[get_session] = lambda: FakeSession()
|
||||
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory()
|
||||
app.dependency_overrides[routers.kimi_oauth._default_http_client] = lambda: http
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _device_auth_response() -> _FakeResponse:
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"device_code": "dev-1",
|
||||
"user_code": "WXYZ-9999",
|
||||
"verification_uri": "https://kimi.com/device",
|
||||
"verification_uri_complete": "https://kimi.com/device?code=WXYZ-9999",
|
||||
"expires_in": 600,
|
||||
"interval": 5,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _token_response() -> _FakeResponse:
|
||||
return _FakeResponse(
|
||||
200, {"access_token": "secret-acc", "refresh_token": "secret-ref", "expires_in": 900}
|
||||
)
|
||||
|
||||
|
||||
def test_start_returns_202_and_polls_to_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
enc_key = Fernet.generate_key().decode()
|
||||
store = FakeCredentialStore()
|
||||
# device auth → poll #1 success(TestClient 同步跑完 background task)。
|
||||
http = _ScriptedHttp([_device_auth_response(), _token_response()])
|
||||
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
|
||||
|
||||
resp = client.post("/settings/providers/kimi-code/oauth/start")
|
||||
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
assert body["user_code"] == "WXYZ-9999"
|
||||
assert body["verification_uri"] == "https://kimi.com/device"
|
||||
assert body["interval"] == 5
|
||||
assert "job_id" in body
|
||||
# 响应绝不含 token。
|
||||
assert "secret-acc" not in resp.text
|
||||
assert "secret-ref" not in resp.text
|
||||
|
||||
# 后台轮询成功 → 加密 oauth 凭据落库(共享 store)。
|
||||
cred = store.oauth.get((STUB_OWNER_ID, "kimi-code"))
|
||||
assert cred is not None
|
||||
token = decrypt_oauth_bundle(cred, key=enc_key)
|
||||
assert token.access_token == "secret-acc"
|
||||
assert token.refresh_token == "secret-ref"
|
||||
|
||||
|
||||
def test_start_polls_through_authorization_pending(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
enc_key = Fernet.generate_key().decode()
|
||||
store = FakeCredentialStore()
|
||||
http = _ScriptedHttp(
|
||||
[
|
||||
_device_auth_response(),
|
||||
_FakeResponse(400, {"error": "authorization_pending"}),
|
||||
_token_response(),
|
||||
]
|
||||
)
|
||||
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
|
||||
|
||||
resp = client.post("/settings/providers/kimi-code/oauth/start")
|
||||
|
||||
assert resp.status_code == 202
|
||||
# pending 后继续轮询 → 第二次成功落库。
|
||||
assert (STUB_OWNER_ID, "kimi-code") in store.oauth
|
||||
|
||||
|
||||
def test_disconnect_clears_credential(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
enc_key = Fernet.generate_key().decode()
|
||||
store = FakeCredentialStore()
|
||||
store.oauth[(STUB_OWNER_ID, "kimi-code")] = b"blob"
|
||||
http = _ScriptedHttp([])
|
||||
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
|
||||
|
||||
resp = client.post("/settings/providers/kimi-code/oauth/disconnect")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["disconnected"] is True
|
||||
assert (STUB_OWNER_ID, "kimi-code") not in store.oauth
|
||||
|
||||
|
||||
def test_status_reflects_connection(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from ww_api.services.kimi_oauth import TokenSet, encrypt_oauth_bundle
|
||||
|
||||
enc_key = Fernet.generate_key().decode()
|
||||
store = FakeCredentialStore()
|
||||
expires = datetime.now(UTC) + timedelta(seconds=900)
|
||||
token = TokenSet(access_token="a", refresh_token="r", expires_at=expires)
|
||||
store.oauth[(STUB_OWNER_ID, "kimi-code")] = encrypt_oauth_bundle(token, key=enc_key)
|
||||
http = _ScriptedHttp([])
|
||||
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
|
||||
|
||||
resp = client.get("/settings/providers/kimi-code/oauth/status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["connected"] is True
|
||||
assert body["expires_at"] is not None
|
||||
# 状态绝不回 token 本体。
|
||||
assert "access_token" not in body
|
||||
assert "refresh_token" not in body
|
||||
|
||||
|
||||
def test_status_not_connected_when_no_credential(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
enc_key = Fernet.generate_key().decode()
|
||||
store = FakeCredentialStore()
|
||||
http = _ScriptedHttp([])
|
||||
client = _make_client(store=store, http=http, enc_key=enc_key, monkeypatch=monkeypatch)
|
||||
|
||||
resp = client.get("/settings/providers/kimi-code/oauth/status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["connected"] is False
|
||||
184
apps/api/tests/test_kimi_oauth_service.py
Normal file
184
apps/api/tests/test_kimi_oauth_service.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""K1.3 单测:Kimi Code OAuth device-flow 服务(fake httpx,绝不联网)。
|
||||
|
||||
覆盖:start_device_authorization、poll_token(成功/pending/slow_down/失败)、refresh、
|
||||
encrypt/decrypt round-trip、needs_refresh 启发式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from ww_api.services.kimi_oauth import (
|
||||
DEVICE_AUTHORIZATION_URL,
|
||||
TOKEN_URL,
|
||||
AuthorizationPending,
|
||||
SlowDown,
|
||||
TokenSet,
|
||||
decrypt_oauth_bundle,
|
||||
encrypt_oauth_bundle,
|
||||
needs_refresh,
|
||||
poll_token,
|
||||
refresh,
|
||||
start_device_authorization,
|
||||
)
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int, body: dict[str, Any]) -> None:
|
||||
self.status_code = status_code
|
||||
self._body = body
|
||||
|
||||
def json(self) -> Any:
|
||||
return self._body
|
||||
|
||||
|
||||
class _FakeHttp:
|
||||
"""记录每次 post 的 url + data,按 url 返回预设响应。"""
|
||||
|
||||
def __init__(self, responses: list[_FakeResponse]) -> None:
|
||||
self._responses = responses
|
||||
self.calls: list[tuple[str, dict[str, str]]] = []
|
||||
|
||||
async def post(self, url: str, *, data: dict[str, str]) -> _FakeResponse:
|
||||
self.calls.append((url, data))
|
||||
return self._responses[len(self.calls) - 1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_device_authorization_parses_response_and_sends_scope() -> None:
|
||||
http = _FakeHttp(
|
||||
[
|
||||
_FakeResponse(
|
||||
200,
|
||||
{
|
||||
"device_code": "dev-123",
|
||||
"user_code": "ABCD-1234",
|
||||
"verification_uri": "https://kimi.com/device",
|
||||
"verification_uri_complete": "https://kimi.com/device?code=ABCD-1234",
|
||||
"expires_in": 600,
|
||||
"interval": 5,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
device = await start_device_authorization(http)
|
||||
|
||||
assert device.device_code == "dev-123"
|
||||
assert device.user_code == "ABCD-1234"
|
||||
assert device.interval == 5
|
||||
assert device.expires_in == 600
|
||||
# 请求打到 device authorization 端点,且**带 scope=kimi-code**(coding entitlement)。
|
||||
url, data = http.calls[0]
|
||||
assert url == DEVICE_AUTHORIZATION_URL
|
||||
assert "client_id" in data
|
||||
assert data["scope"] == "kimi-code"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_token_success_returns_token_set() -> None:
|
||||
http = _FakeHttp(
|
||||
[
|
||||
_FakeResponse(
|
||||
200,
|
||||
{
|
||||
"access_token": "acc-xyz",
|
||||
"refresh_token": "ref-xyz",
|
||||
"expires_in": 900,
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
token = await poll_token(http, "dev-123")
|
||||
|
||||
assert token.access_token == "acc-xyz"
|
||||
assert token.refresh_token == "ref-xyz"
|
||||
assert token.expires_at > datetime.now(UTC)
|
||||
# 打到 token 端点 + device_code grant。
|
||||
url, data = http.calls[0]
|
||||
assert url == TOKEN_URL
|
||||
assert data["device_code"] == "dev-123"
|
||||
assert data["grant_type"].endswith("device_code")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_token_authorization_pending_raises_pending() -> None:
|
||||
http = _FakeHttp([_FakeResponse(400, {"error": "authorization_pending"})])
|
||||
with pytest.raises(AuthorizationPending):
|
||||
await poll_token(http, "dev-123")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_token_slow_down_raises_slow_down() -> None:
|
||||
http = _FakeHttp([_FakeResponse(400, {"error": "slow_down"})])
|
||||
with pytest.raises(SlowDown):
|
||||
await poll_token(http, "dev-123")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_token_expired_raises_app_error() -> None:
|
||||
http = _FakeHttp([_FakeResponse(400, {"error": "expired_token"})])
|
||||
with pytest.raises(AppError) as exc:
|
||||
await poll_token(http, "dev-123")
|
||||
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_returns_new_token_set() -> None:
|
||||
http = _FakeHttp(
|
||||
[
|
||||
_FakeResponse(
|
||||
200, {"access_token": "new-acc", "refresh_token": "new-ref", "expires_in": 900}
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
token = await refresh(http, "ref-old")
|
||||
|
||||
assert token.access_token == "new-acc"
|
||||
assert token.refresh_token == "new-ref"
|
||||
url, data = http.calls[0]
|
||||
assert url == TOKEN_URL
|
||||
assert data["grant_type"] == "refresh_token"
|
||||
assert data["refresh_token"] == "ref-old"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_failure_raises_app_error() -> None:
|
||||
http = _FakeHttp([_FakeResponse(400, {"error": "invalid_grant"})])
|
||||
with pytest.raises(AppError) as exc:
|
||||
await refresh(http, "ref-bad")
|
||||
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
|
||||
|
||||
|
||||
def test_encrypt_decrypt_oauth_bundle_round_trip() -> None:
|
||||
key = Fernet.generate_key().decode()
|
||||
expires = datetime.now(UTC) + timedelta(seconds=900)
|
||||
token = TokenSet(access_token="acc", refresh_token="ref", expires_at=expires)
|
||||
|
||||
blob = encrypt_oauth_bundle(token, key=key)
|
||||
restored = decrypt_oauth_bundle(blob, key=key)
|
||||
|
||||
assert restored.access_token == "acc"
|
||||
assert restored.refresh_token == "ref"
|
||||
assert restored.expires_at == expires
|
||||
# 密文不含明文 token(加密真生效)。
|
||||
assert b"acc" not in blob
|
||||
assert b"ref" not in blob
|
||||
|
||||
|
||||
def test_needs_refresh_true_when_near_expiry() -> None:
|
||||
now = datetime.now(UTC)
|
||||
near = TokenSet(access_token="a", refresh_token="r", expires_at=now + timedelta(seconds=60))
|
||||
assert needs_refresh(near, now=now) is True
|
||||
|
||||
|
||||
def test_needs_refresh_false_when_fresh() -> None:
|
||||
now = datetime.now(UTC)
|
||||
fresh = TokenSet(access_token="a", refresh_token="r", expires_at=now + timedelta(seconds=900))
|
||||
assert needs_refresh(fresh, now=now) is False
|
||||
@@ -16,6 +16,7 @@ import httpx
|
||||
import pytest
|
||||
from fakes_projects import (
|
||||
FakeForeshadowRepo,
|
||||
FakeOutlineReadRepo,
|
||||
FakeOutlineWriteRepo,
|
||||
FakeProjectRepo,
|
||||
FakeReviewGateway,
|
||||
@@ -186,6 +187,84 @@ async def test_generate_outline_upper_error_propagates() -> None:
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
# ---- 大纲读取(GET /outline)----
|
||||
|
||||
|
||||
def _make_read_client(
|
||||
*,
|
||||
project_repo: FakeProjectRepo,
|
||||
outline_read_repo: FakeOutlineReadRepo,
|
||||
) -> httpx.AsyncClient:
|
||||
"""装配 GET /outline 测试客户端(注入 fake 项目 repo + 读侧 outline repo)。"""
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_outline_read_repo, get_project_repo
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_outline_read_repo] = lambda: outline_read_repo
|
||||
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_outline_returns_persisted_chapters_in_order_with_unpacked_beats() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
read_repo = FakeOutlineReadRepo()
|
||||
# 乱序入种,断言端点按 chapter_no 升序返回。
|
||||
read_repo.add_chapter(pid, volume=1, chapter_no=2, beats=["冲突升级"])
|
||||
read_repo.add_chapter(
|
||||
pid,
|
||||
volume=1,
|
||||
chapter_no=1,
|
||||
beats=["开篇引入主角", "埋下信物伏笔"],
|
||||
foreshadow_windows=[{"code": "F1", "plant_chapter": 1, "expected_close_to": 10}],
|
||||
)
|
||||
client = _make_read_client(project_repo=project_repo, outline_read_repo=read_repo)
|
||||
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{pid}/outline")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert [c["no"] for c in body["chapters"]] == [1, 2]
|
||||
ch1 = body["chapters"][0]
|
||||
# beats 由 DB dict `{"beats": [...]}` 解包成裸 list(与 POST 响应同形)。
|
||||
assert ch1["beats"] == ["开篇引入主角", "埋下信物伏笔"]
|
||||
assert ch1["foreshadow_windows"][0]["code"] == "F1"
|
||||
assert ch1["foreshadow_windows"][0]["expected_close_to"] == 10
|
||||
assert body["chapters"][1]["beats"] == ["冲突升级"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_outline_returns_empty_list_when_no_outline() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
client = _make_read_client(project_repo=project_repo, outline_read_repo=FakeOutlineReadRepo())
|
||||
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{pid}/outline")
|
||||
|
||||
# 项目存在但无大纲 → 200 空列表(非 404)。
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["chapters"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_outline_unknown_project_404() -> None:
|
||||
client = _make_read_client(
|
||||
project_repo=FakeProjectRepo(), outline_read_repo=FakeOutlineReadRepo()
|
||||
)
|
||||
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{uuid.uuid4()}/outline")
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
# ---- 伏笔看板 ----
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from ww_core.domain.repositories import (
|
||||
ForeshadowView,
|
||||
MemoryRepos,
|
||||
OutlineView,
|
||||
ProjectSpecView,
|
||||
RuleView,
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
@@ -27,6 +28,9 @@ class _EmptyOutlineRepo:
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return None
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
|
||||
return []
|
||||
|
||||
|
||||
class _EmptyCharacterRepo:
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
|
||||
@@ -58,6 +62,11 @@ class _EmptyRulesRepo:
|
||||
return []
|
||||
|
||||
|
||||
class _StubProjectSpecRepo:
|
||||
async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
|
||||
return ProjectSpecView(title="测试作品", premise="测试前提")
|
||||
|
||||
|
||||
def _empty_memory_repos() -> MemoryRepos:
|
||||
return MemoryRepos(
|
||||
outline=_EmptyOutlineRepo(),
|
||||
@@ -67,6 +76,7 @@ def _empty_memory_repos() -> MemoryRepos:
|
||||
foreshadow=_EmptyForeshadowRepo(),
|
||||
style=_EmptyStyleRepo(),
|
||||
rules=_EmptyRulesRepo(),
|
||||
project=_StubProjectSpecRepo(),
|
||||
)
|
||||
|
||||
|
||||
@@ -208,3 +218,30 @@ async def test_put_draft_is_idempotent() -> None:
|
||||
assert saved.content == "改稿更长"
|
||||
assert saved.version == 1
|
||||
assert r2.json()["length"] == len("改稿更长")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_draft_returns_saved_content() -> None:
|
||||
chapter_repo = FakeChapterRepo()
|
||||
client, _, _, _ = _make_client(chapter_repo=chapter_repo)
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
await client.put(f"/projects/{pid}/chapters/5/draft", json={"text": "阿福重访工作台"})
|
||||
resp = await client.get(f"/projects/{pid}/chapters/5/draft")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["content"] == "阿福重访工作台"
|
||||
assert body["status"] == "draft"
|
||||
assert body["version"] == 1
|
||||
assert body["chapter_no"] == 5
|
||||
assert body["length"] == len("阿福重访工作台")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_draft_404_when_no_draft() -> None:
|
||||
client, _, _, _ = _make_client()
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
resp = await client.get(f"/projects/{pid}/chapters/9/draft")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
87
apps/api/tests/test_rules.py
Normal file
87
apps/api/tests/test_rules.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""T5.5 POST /projects/:id/rules 端点(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- 201 + 回显 level/content + 端点 commit;
|
||||
- 非法 level → 422(Pydantic Literal 校验,FastAPI 422);
|
||||
- 空 content → 422。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fakes_projects import FakeSession
|
||||
from ww_core.domain.rule_repo import RuleWriteView
|
||||
|
||||
|
||||
class _FakeRuleWriteRepo:
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[RuleWriteView] = []
|
||||
|
||||
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
|
||||
view = RuleWriteView(project_id=project_id, level=level, content=content)
|
||||
self.rows.append(view)
|
||||
return view
|
||||
|
||||
|
||||
def _make_client() -> tuple[httpx.AsyncClient, _FakeRuleWriteRepo, FakeSession]:
|
||||
import os
|
||||
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_rule_write_repo
|
||||
from ww_db import get_session
|
||||
|
||||
repo = _FakeRuleWriteRepo()
|
||||
session = FakeSession()
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_rule_write_repo] = lambda: repo
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
return client, repo, session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rule_returns_201_and_commits() -> None:
|
||||
client, repo, session = _make_client()
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/rules",
|
||||
json={"level": "project", "content": "主角不许中途复活"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["level"] == "project"
|
||||
assert body["content"] == "主角不许中途复活"
|
||||
assert session.commits == 1
|
||||
assert len(repo.rows) == 1
|
||||
assert repo.rows[0].project_id == pid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rule_invalid_level_returns_422() -> None:
|
||||
client, _repo, _session = _make_client()
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/rules",
|
||||
json={"level": "nonsense", "content": "x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rule_empty_content_returns_422() -> None:
|
||||
client, _repo, _session = _make_client()
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/rules",
|
||||
json={"level": "global", "content": ""},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
394
apps/api/tests/test_style.py
Normal file
394
apps/api/tests/test_style.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""T4.3 端点测试:学文风(jobs 异步)+ 回炉 + 最新指纹(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- POST /style:写一行 job 返 202 `{job_id}`;BackgroundTask 跑完写 style_fingerprint
|
||||
+ 置 job done(ASGITransport 下 background task 在请求返回前跑完,见 gotcha);
|
||||
- mode="update" → 第二次 append version+1;
|
||||
- 项目不存在 → 404;无凭据 → 503(dep 解析阶段拦下,未写 job);
|
||||
- GET /style:返最新指纹(dimensions/evidence/version);无指纹 → 404;
|
||||
- POST /refine:返 {original, refined}(refiner 纯文本)+ 末尾 commit(记账落库);无凭据 → 503。
|
||||
|
||||
学文风 work 在 `run_job` 自建独立 session 上自造网关 + repo(请求 session 已关闭)——
|
||||
测试 monkeypatch `build_gateway_for_tier`(产 StyleFingerprintResult)+ 写侧 repo
|
||||
(指到共享 fake repo),并 override `get_session_factory` 为 FakeSessionFactory,断言后台落库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fakes_projects import (
|
||||
FakeJobRepo,
|
||||
FakeProjectRepo,
|
||||
FakeReviewGateway,
|
||||
FakeSession,
|
||||
FakeSessionFactory,
|
||||
FakeStyleWriteRepo,
|
||||
)
|
||||
from fastapi import FastAPI
|
||||
from ww_agents import StyleDimension, StyleFingerprintResult
|
||||
from ww_core.domain.job_repo import STATUS_DONE
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
|
||||
def _fingerprint() -> StyleFingerprintResult:
|
||||
return StyleFingerprintResult(
|
||||
dimensions=[
|
||||
StyleDimension(name="句长节奏", value="短句为主", evidence=["他来了。", "她走了。"]),
|
||||
StyleDimension(name="叙事人称", value="第三人称", evidence=["他看着远方。"]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
|
||||
view = await repo.create(uuid.UUID(int=1), ProjectCreate(title="测试作品", genre="玄幻"))
|
||||
return uuid.UUID(str(view.id))
|
||||
|
||||
|
||||
def _app_with_overrides(
|
||||
*,
|
||||
project_repo: FakeProjectRepo,
|
||||
job_repo: FakeJobRepo | None = None,
|
||||
style_repo: FakeStyleWriteRepo | None = None,
|
||||
session: FakeSession | None = None,
|
||||
session_factory: FakeSessionFactory | None = None,
|
||||
extract_gateway: object | None = None,
|
||||
refine_gateway: object | None = None,
|
||||
) -> FastAPI:
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import (
|
||||
get_job_repo,
|
||||
get_project_repo,
|
||||
get_refine_gateway,
|
||||
get_session_factory,
|
||||
get_style_extract_gateway,
|
||||
get_style_write_repo,
|
||||
)
|
||||
from ww_db import get_session
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_session] = lambda: session or FakeSession()
|
||||
if job_repo is not None:
|
||||
app.dependency_overrides[get_job_repo] = lambda: job_repo
|
||||
if style_repo is not None:
|
||||
app.dependency_overrides[get_style_write_repo] = lambda: style_repo
|
||||
if session_factory is not None:
|
||||
app.dependency_overrides[get_session_factory] = lambda: session_factory
|
||||
if extract_gateway is not None:
|
||||
app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway
|
||||
if refine_gateway is not None:
|
||||
app.dependency_overrides[get_refine_gateway] = lambda: refine_gateway
|
||||
return app
|
||||
|
||||
|
||||
def _client(app: FastAPI) -> httpx.AsyncClient:
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
|
||||
# ---- POST /style(学文风 jobs 异步)----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_learn_style_returns_202_and_writes_job_row(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
job_repo = FakeJobRepo()
|
||||
session = FakeSession()
|
||||
app = _app_with_overrides(
|
||||
project_repo=project_repo,
|
||||
job_repo=job_repo,
|
||||
session=session,
|
||||
session_factory=FakeSessionFactory(),
|
||||
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
|
||||
)
|
||||
|
||||
# background task work 触网关/repo → monkeypatch 成 no-op fake(本用例不验落库);
|
||||
# run_job 默认 repo_factory 用 SqlJobRepo(FakeSession) 会触 .execute → 改用 fake job repo。
|
||||
import ww_api.routers.style as style_mod
|
||||
import ww_api.services.job_runner as runner_mod
|
||||
|
||||
async def _noop_work(_session: object) -> dict[str, int]:
|
||||
return {"version": 1, "dims_count": 2}
|
||||
|
||||
monkeypatch.setattr(style_mod, "_make_style_learn_work", lambda *_a, **_k: _noop_work)
|
||||
monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/style", json={"samples": ["样本正文一"]})
|
||||
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
job_id = uuid.UUID(body["job_id"])
|
||||
# job 行已创建 + 请求阶段 commit 一次(供前端立即轮询)。
|
||||
assert job_id in job_repo.rows
|
||||
assert job_repo.rows[job_id].kind == "style_learn"
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_learn_style_background_task_writes_fingerprint_and_completes_job(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
job_repo = FakeJobRepo()
|
||||
style_repo = FakeStyleWriteRepo()
|
||||
session_factory = FakeSessionFactory()
|
||||
|
||||
import ww_api.routers.style as style_mod
|
||||
|
||||
# work 内 build_gateway_for_tier → fake 网关产指纹;写侧 repo → 共享 fake repo。
|
||||
async def _fake_build_gateway(_session, _store, _tier): # type: ignore[no-untyped-def]
|
||||
return FakeReviewGateway(parsed=_fingerprint())
|
||||
|
||||
monkeypatch.setattr(style_mod, "build_gateway_for_tier", _fake_build_gateway)
|
||||
monkeypatch.setattr(style_mod, "SqlStyleFingerprintWriteRepo", lambda _s: style_repo)
|
||||
# `run_job` 用 SqlJobRepo(默认 repo_factory)会触 FakeSession.execute → 改用 fake job repo。
|
||||
import ww_api.services.job_runner as runner_mod
|
||||
|
||||
monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)
|
||||
|
||||
app = _app_with_overrides(
|
||||
project_repo=project_repo,
|
||||
job_repo=job_repo,
|
||||
session=FakeSession(),
|
||||
session_factory=session_factory,
|
||||
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
|
||||
)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/style", json={"samples": ["样本一", "样本二"], "mode": "create"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 202
|
||||
job_id = uuid.UUID(resp.json()["job_id"])
|
||||
# BackgroundTask 已跑完(ASGITransport 等其完成):style_fingerprint 落库 + job done。
|
||||
assert style_repo.append_calls == 1
|
||||
assert style_repo.rows[1][0] == {"句长节奏": "短句为主", "叙事人称": "第三人称"}
|
||||
assert style_repo.rows[1][1]["句长节奏"] == ["他来了。", "她走了。"]
|
||||
assert job_repo.rows[job_id].status == STATUS_DONE
|
||||
assert job_repo.rows[job_id].result == {"version": 1, "dims_count": 2}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_learn_style_update_mode_bumps_version(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
job_repo = FakeJobRepo()
|
||||
style_repo = FakeStyleWriteRepo()
|
||||
# 预置 version 1(首学已存在)。
|
||||
await style_repo.append(pid, dimensions_json={"x": "y"}, evidence_json={"x": []})
|
||||
|
||||
import ww_api.routers.style as style_mod
|
||||
import ww_api.services.job_runner as runner_mod
|
||||
|
||||
async def _fake_build_gateway(_session, _store, _tier): # type: ignore[no-untyped-def]
|
||||
return FakeReviewGateway(parsed=_fingerprint())
|
||||
|
||||
monkeypatch.setattr(style_mod, "build_gateway_for_tier", _fake_build_gateway)
|
||||
monkeypatch.setattr(style_mod, "SqlStyleFingerprintWriteRepo", lambda _s: style_repo)
|
||||
monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)
|
||||
|
||||
app = _app_with_overrides(
|
||||
project_repo=project_repo,
|
||||
job_repo=job_repo,
|
||||
session=FakeSession(),
|
||||
session_factory=FakeSessionFactory(),
|
||||
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
|
||||
)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/style", json={"samples": ["新样本"], "mode": "update"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 202
|
||||
job_id = uuid.UUID(resp.json()["job_id"])
|
||||
# 第二次 append → version 2(version+1)。
|
||||
assert max(style_repo.rows) == 2
|
||||
assert job_repo.rows[job_id].result == {"version": 2, "dims_count": 2}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_learn_style_unknown_project_404() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
app = _app_with_overrides(
|
||||
project_repo=project_repo,
|
||||
job_repo=FakeJobRepo(),
|
||||
session_factory=FakeSessionFactory(),
|
||||
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
|
||||
)
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{uuid.uuid4()}/style", json={"samples": ["x"]})
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_learn_style_without_credentials_503_and_no_job() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
job_repo = FakeJobRepo()
|
||||
session = FakeSession()
|
||||
|
||||
async def _no_creds() -> object:
|
||||
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
|
||||
|
||||
app = _app_with_overrides(
|
||||
project_repo=project_repo,
|
||||
job_repo=job_repo,
|
||||
session=session,
|
||||
session_factory=FakeSessionFactory(),
|
||||
)
|
||||
from ww_api.services.project_deps import get_style_extract_gateway
|
||||
|
||||
app.dependency_overrides[get_style_extract_gateway] = _no_creds
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/style", json={"samples": ["x"]})
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
# 凭据探测在调度 job 之前 → 未写 job、未 commit。
|
||||
assert job_repo.rows == {}
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_learn_style_empty_samples_422() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
app = _app_with_overrides(
|
||||
project_repo=project_repo,
|
||||
job_repo=FakeJobRepo(),
|
||||
session_factory=FakeSessionFactory(),
|
||||
extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
|
||||
)
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/style", json={"samples": []})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---- GET /style(最新指纹)----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_style_returns_latest_fingerprint() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
style_repo = FakeStyleWriteRepo()
|
||||
await style_repo.append(
|
||||
pid, dimensions_json={"句长": "短"}, evidence_json={"句长": ["他来了。"]}
|
||||
)
|
||||
app = _app_with_overrides(project_repo=project_repo, style_repo=style_repo)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.get(f"/projects/{pid}/style")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["version"] == 1
|
||||
assert body["dimensions"] == {"句长": "短"}
|
||||
assert body["evidence"] == {"句长": ["他来了。"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_style_no_fingerprint_404() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
app = _app_with_overrides(project_repo=project_repo, style_repo=FakeStyleWriteRepo())
|
||||
async with _client(app) as client:
|
||||
resp = await client.get(f"/projects/{pid}/style")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_style_unknown_project_404() -> None:
|
||||
app = _app_with_overrides(project_repo=FakeProjectRepo(), style_repo=FakeStyleWriteRepo())
|
||||
async with _client(app) as client:
|
||||
resp = await client.get(f"/projects/{uuid.uuid4()}/style")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---- POST /refine(同步回炉)----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refine_returns_original_and_refined_and_commits() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
gateway = FakeReviewGateway(text="重写后的段落。")
|
||||
app = _app_with_overrides(project_repo=project_repo, session=session, refine_gateway=gateway)
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/chapters/1/refine",
|
||||
json={"segment": "原始段落。", "instruction": "去掉机翻腔"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["original"] == "原始段落。"
|
||||
assert body["refined"] == "重写后的段落。"
|
||||
# writer 档 + 末尾 commit(记账落库)。
|
||||
assert gateway.requests[0].tier == "writer"
|
||||
assert session.commits == 1
|
||||
# 指令进入了输入文本。
|
||||
assert "去掉机翻腔" in str(gateway.requests[0].input)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refine_unknown_project_404() -> None:
|
||||
app = _app_with_overrides(
|
||||
project_repo=FakeProjectRepo(), refine_gateway=FakeReviewGateway(text="x")
|
||||
)
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(
|
||||
f"/projects/{uuid.uuid4()}/chapters/1/refine", json={"segment": "原段"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refine_empty_segment_422() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
app = _app_with_overrides(project_repo=project_repo, refine_gateway=FakeReviewGateway(text="x"))
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/chapters/1/refine", json={"segment": ""})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refine_without_credentials_503() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
|
||||
async def _no_creds() -> object:
|
||||
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
|
||||
|
||||
app = _app_with_overrides(project_repo=project_repo, session=session)
|
||||
from ww_api.services.project_deps import get_refine_gateway
|
||||
|
||||
app.dependency_overrides[get_refine_gateway] = _no_creds
|
||||
|
||||
async with _client(app) as client:
|
||||
resp = await client.post(f"/projects/{pid}/chapters/1/refine", json={"segment": "原段"})
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
assert session.commits == 0
|
||||
@@ -6,7 +6,10 @@ from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from ww_config import get_settings
|
||||
from ww_core.domain.job_repo import SqlJobRepo
|
||||
from ww_db import get_sessionmaker
|
||||
from ww_shared import AppError, ErrorBody, ErrorEnvelope
|
||||
|
||||
@@ -14,11 +17,15 @@ from ww_api.logging_config import configure_logging, get_logger
|
||||
from ww_api.middleware import request_id_middleware
|
||||
from ww_api.routers import (
|
||||
foreshadow,
|
||||
generation,
|
||||
health,
|
||||
jobs,
|
||||
kimi_oauth,
|
||||
outline,
|
||||
projects,
|
||||
rules,
|
||||
settings_providers,
|
||||
style,
|
||||
)
|
||||
from ww_api.services.project_deps import seed_stub_user
|
||||
|
||||
@@ -31,11 +38,28 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
# 幂等 seed 单用户 stub——所有 owner_id FK 依赖它(见 memory/gotchas)。
|
||||
async with get_sessionmaker()() as session:
|
||||
await seed_stub_user(session)
|
||||
# zombie reaper(M4-d / §7.4 缓解):进程重启会丢未跑完的 BackgroundTask,把残留
|
||||
# status=running 的 job 标 failed(让用户看到失败可重试,而非进度条永转)。自 commit、幂等。
|
||||
async with get_sessionmaker()() as session:
|
||||
reaped = await SqlJobRepo(session).reap_zombies()
|
||||
if reaped:
|
||||
log.info("job_zombies_reaped", count=reaped)
|
||||
yield
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="网文创作工作流 API", version="0.0.0", lifespan=_lifespan)
|
||||
# CORS:前端(Next.js)与 API 分端口/跨源,浏览器端 `api.POST/PUT` 需 CORS 放行
|
||||
# (RSC 服务端取数同源、无需 CORS,故此前同进程测试从未暴露此缺口)。原型单用户、
|
||||
# 本地开发:放行 localhost:3000;可经 env `CORS_ORIGINS`(逗号分隔)覆盖。
|
||||
settings = get_settings()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.middleware("http")(request_id_middleware)
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
@@ -57,7 +81,12 @@ def create_app() -> FastAPI:
|
||||
app.include_router(projects.router)
|
||||
app.include_router(foreshadow.router)
|
||||
app.include_router(outline.router)
|
||||
app.include_router(rules.router)
|
||||
app.include_router(style.router)
|
||||
app.include_router(generation.router)
|
||||
app.include_router(generation.skills_router)
|
||||
app.include_router(settings_providers.router)
|
||||
app.include_router(kimi_oauth.router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
427
apps/api/ww_api/routers/generation.py
Normal file
427
apps/api/ww_api/routers/generation.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""生成/入库端点(C3 扩 / ARCH §6.5 角色生成器 / §5.4 / §7.2;不变量 #1/#3/#9)。
|
||||
|
||||
三类端点 + 两个读端点(M5-d 生成走即时返回:预览 → 作者确认 → 入库,非 jobs):
|
||||
- `POST /projects/:id/world/generate`:worldbuilder(writer 网关)→ **预览**世界观实体(不入库)。
|
||||
- `POST /projects/:id/characters/generate`:character-gen(writer 网关,群像防雷同)→ 预览角色卡。
|
||||
- `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`:技能库列表(技能库 UI,T5.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)
|
||||
|
||||
# 提交边界:网关 ledger(precheck)+ 角色写侧均只 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":[...]}` → 裸 list(worldbuilder 形变的逆向)。
|
||||
"""
|
||||
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
|
||||
],
|
||||
)
|
||||
190
apps/api/ww_api/routers/kimi_oauth.py
Normal file
190
apps/api/ww_api/routers/kimi_oauth.py
Normal 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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
53
apps/api/ww_api/routers/rules.py
Normal file
53
apps/api/ww_api/routers/rules.py
Normal 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)
|
||||
225
apps/api/ww_api/routers/style.py
Normal file
225
apps/api/ww_api/routers/style.py
Normal 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}`:学文风走 jobs(M4-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, # 不变量 #2:writer 档,不传 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)
|
||||
163
apps/api/ww_api/schemas/generation.py
Normal file
163
apps/api/ww_api/schemas/generation.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""生成/入库端点的请求/响应 schema(C3 扩 / ARCH §6.5 / §7.2)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
|
||||
|
||||
生成走**即时返回**(预览 → 作者确认 → 入库),非 jobs 长任务(M5-d):
|
||||
- `POST /world/generate` / `POST /characters/generate`:返回**预览**(不持久化)。
|
||||
- `POST /characters`(入库):作者确认的角色卡 → 过 continuity 预检 gate + 权限白名单 → 写库。
|
||||
|
||||
视图字段贴 `ww_agents.WorldEntityCard`/`CharacterCard`(保持生成产物形:traits/speech_tics
|
||||
是 list、arc 是 str;入库时由写侧 repo 转 DB JSONB 形,见 character_repo)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ww_api.schemas.rules import RuleView
|
||||
|
||||
# 批量生成数量上限(防一次性巨量调用;原型保守值)。
|
||||
MAX_GENERATE_COUNT = 12
|
||||
|
||||
|
||||
# ---- 世界观生成(预览)----
|
||||
|
||||
|
||||
class WorldGenerateRequest(BaseModel):
|
||||
"""POST /projects/:id/world/generate:据需求生成世界观实体(预览)。"""
|
||||
|
||||
brief: str = Field(min_length=1, description="世界观生成需求(题材/设定方向/约束)")
|
||||
|
||||
|
||||
class WorldEntityCardView(BaseModel):
|
||||
"""单个世界观实体卡(贴 ww_agents.WorldEntityCard;预览 + 入库共用形)。"""
|
||||
|
||||
type: str = Field(description="实体类型(势力 / 地理 / 力量体系 / 物品 / 概念 等)")
|
||||
name: str = Field(description="实体名")
|
||||
rules: list[str] = Field(default_factory=list, description="该实体的硬规则清单")
|
||||
|
||||
|
||||
class WorldGenPreviewResponse(BaseModel):
|
||||
"""世界观生成预览(不持久化;作者确认后另走入库,本期入库端点为角色)。"""
|
||||
|
||||
entities: list[WorldEntityCardView] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---- 角色生成(预览)----
|
||||
|
||||
|
||||
class CharacterGenerateRequest(BaseModel):
|
||||
"""POST /projects/:id/characters/generate:据需求生成角色卡(预览,群像防雷同)。"""
|
||||
|
||||
brief: str = Field(min_length=1, description="角色生成需求")
|
||||
count: int = Field(default=1, ge=1, le=MAX_GENERATE_COUNT, description="生成数量")
|
||||
role: str | None = Field(default=None, description="角色定位(主角/CP/对手/导师/工具人 等)")
|
||||
|
||||
|
||||
class CharacterRelationView(BaseModel):
|
||||
"""单条人物关系(贴 ww_agents.CharacterRelation)。"""
|
||||
|
||||
name: str
|
||||
kind: str
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class CharacterCardView(BaseModel):
|
||||
"""单张角色卡(贴 ww_agents.CharacterCard;预览 + 入库请求共用形)。"""
|
||||
|
||||
name: str = Field(description="角色名")
|
||||
role: str = Field(description="角色定位")
|
||||
traits: list[str] = Field(default_factory=list, description="性格特质清单")
|
||||
backstory: str = Field(description="背景故事")
|
||||
arc: str = Field(description="人物弧光(一句话)")
|
||||
speech_tics: list[str] = Field(default_factory=list, description="口癖/语言风格")
|
||||
tags: list[str] = Field(default_factory=list, description="人设标签/萌点")
|
||||
relations: list[CharacterRelationView] = Field(default_factory=list, description="关系网")
|
||||
|
||||
|
||||
class CharacterGenPreviewResponse(BaseModel):
|
||||
"""角色生成预览(不持久化;作者确认后经 POST /characters 入库)。"""
|
||||
|
||||
cards: list[CharacterCardView] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---- 角色入库(作者确认后;过 continuity gate + 权限白名单)----
|
||||
|
||||
|
||||
class CharacterIngestRequest(BaseModel):
|
||||
"""POST /projects/:id/characters:把作者确认的角色卡入库。
|
||||
|
||||
`acknowledge_conflicts`:作者已查看并接受预检冲突时置 `true`,越过 continuity gate
|
||||
(仿 accept 的冲突裁决:不静默入库,须作者确认;默认 false → 有冲突即 409,见端点)。
|
||||
"""
|
||||
|
||||
cards: list[CharacterCardView] = Field(min_length=1, description="待入库的角色卡(至少 1 张)")
|
||||
acknowledge_conflicts: bool = Field(
|
||||
default=False, description="作者已知悉并接受 continuity 冲突 → 放行入库"
|
||||
)
|
||||
|
||||
|
||||
class IngestConflictView(BaseModel):
|
||||
"""入库预检检出的单条 continuity 冲突(贴 ww_agents.Conflict 五类)。"""
|
||||
|
||||
type: str
|
||||
where: str
|
||||
refs: list[str] = Field(default_factory=list)
|
||||
suggestion: str
|
||||
|
||||
|
||||
class CharacterIngestResponse(BaseModel):
|
||||
"""角色入库结果(201):写入的角色 + 被白名单丢弃的越权表(审计)。"""
|
||||
|
||||
created: list[str] = Field(default_factory=list, description="写入的角色名(按入参顺序)")
|
||||
rejected_tables: list[str] = Field(
|
||||
default_factory=list, description="被权限白名单丢弃的越权写表名(审计;正常为空)"
|
||||
)
|
||||
|
||||
|
||||
# ---- 读端点:设定库 Codex(角色 / 世界观全量列表)----
|
||||
|
||||
|
||||
class CharacterListResponse(BaseModel):
|
||||
"""GET /projects/:id/characters:已入库角色全量列表(设定库 Codex 真源)。
|
||||
|
||||
DB JSONB dict 列 → API list/str 反向解包(入库形变的逆向,见 character_repo):
|
||||
`{"items":[...]}`→list、`{"text":...}`→str;tags/relations 直落 list。
|
||||
"""
|
||||
|
||||
characters: list[CharacterCardView] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorldEntityListResponse(BaseModel):
|
||||
"""GET /projects/:id/world_entities:已入库世界观实体全量列表(设定库 Codex 真源)。
|
||||
|
||||
DB JSONB dict 列 `{"rules":[...]}`→裸 list(worldbuilder 形变的逆向)。
|
||||
"""
|
||||
|
||||
world_entities: list[WorldEntityCardView] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---- 读端点:规则列表 + 技能库(T5.6 前端)----
|
||||
|
||||
|
||||
class RuleListResponse(BaseModel):
|
||||
"""GET /projects/:id/rules:规则列表(规则页)。"""
|
||||
|
||||
rules: list[RuleView] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillView(BaseModel):
|
||||
"""单个 skill 的声明式视图(技能库 UI)。"""
|
||||
|
||||
name: str
|
||||
scope: str = Field(description="builtin / custom / community")
|
||||
tier: str = Field(description="能力档位(writer / analyst / light)")
|
||||
reads: list[str] = Field(default_factory=list)
|
||||
writes: list[str] = Field(default_factory=list)
|
||||
genre: str | None = None
|
||||
|
||||
|
||||
class SkillListResponse(BaseModel):
|
||||
"""GET /skills:技能库列表(按 name 升序)。"""
|
||||
|
||||
skills: list[SkillView] = Field(default_factory=list)
|
||||
39
apps/api/ww_api/schemas/kimi_oauth.py
Normal file
39
apps/api/ww_api/schemas/kimi_oauth.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Kimi Code OAuth device-flow 端点的响应 schema(C3 扩 K1.3 / ARCH §7.2 / §7.4)。
|
||||
|
||||
snake_case(命名契约,见 memory/gotchas)。前端经 OpenAPI→TS 客户端消费——改字段须
|
||||
`pnpm gen:api` 重生成。**响应绝不含 access/refresh token**(只 user_code + 轮询信息)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OAuthStartResponse(BaseModel):
|
||||
"""device flow 启动:返回 job_id(轮询 `GET /jobs/{id}`)+ 用户面展示信息。
|
||||
|
||||
前端展示 `user_code`、打开 `verification_uri`(或 `verification_uri_complete`),并按
|
||||
`interval` 轮询 job 直到 `done`(已连接)/`failed`(过期/拒绝)。**无 token**。
|
||||
"""
|
||||
|
||||
job_id: uuid.UUID
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
verification_uri_complete: str | None = None
|
||||
expires_in: int
|
||||
interval: int
|
||||
|
||||
|
||||
class OAuthStatusResponse(BaseModel):
|
||||
"""连接状态:是否已连接 + access token 过期时刻(ISO8601;**无 token 本体**)。"""
|
||||
|
||||
connected: bool
|
||||
expires_at: str | None = None
|
||||
|
||||
|
||||
class OAuthDisconnectResponse(BaseModel):
|
||||
"""断开:是否删到凭据行。"""
|
||||
|
||||
disconnected: bool
|
||||
@@ -59,6 +59,22 @@ class DraftResponse(BaseModel):
|
||||
length: int
|
||||
|
||||
|
||||
class DraftView(BaseModel):
|
||||
"""GET .../draft:回灌已保存草稿(含正文,供工作台重载编辑器)。
|
||||
|
||||
与 PUT 的 `DraftResponse` 同元信息,**额外带 `content`**——读侧需要正文以重建编辑器,
|
||||
保存侧不回灌正文故不带。`length` 仍按 content 字符数派生。
|
||||
"""
|
||||
|
||||
project_id: uuid.UUID
|
||||
chapter_no: int
|
||||
volume: int
|
||||
status: str
|
||||
version: int
|
||||
content: str
|
||||
length: int
|
||||
|
||||
|
||||
# ---- 审稿(T2.5)----
|
||||
|
||||
|
||||
|
||||
28
apps/api/ww_api/schemas/rules.py
Normal file
28
apps/api/ww_api/schemas/rules.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""规则端点的请求/响应 schema(C3 扩 / PRODUCT_SPEC §7 POST /rules)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
|
||||
`level` ∈ global/genre/style/project(四级合并优先级,见 memory `merge_rules`)。
|
||||
加规则是作者显式动作——审稿发现的问题/亮点随手沉淀为规则(非 AI 静默写库,不变量 #3)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
RuleLevel = Literal["global", "genre", "style", "project"]
|
||||
|
||||
|
||||
class RuleCreateRequest(BaseModel):
|
||||
"""POST /projects/:id/rules:新增一条规则。"""
|
||||
|
||||
level: RuleLevel = Field(description="规则级别(global/genre/style/project,越具体越优先)")
|
||||
content: str = Field(min_length=1, description="规则正文")
|
||||
|
||||
|
||||
class RuleView(BaseModel):
|
||||
"""规则视图(创建后回显;snake_case)。"""
|
||||
|
||||
level: str
|
||||
content: str
|
||||
47
apps/api/ww_api/schemas/style.py
Normal file
47
apps/api/ww_api/schemas/style.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""文风学习 + 回炉端点的请求/响应 schema(C3 扩 / ARCH §7.2 / UX §6.9 / §8.3)。
|
||||
|
||||
snake_case(命名契约,见 memory/gotchas)。前端经 OpenAPI→TS 客户端消费——改字段
|
||||
须 `pnpm gen:api` 重生成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class StyleLearnRequest(BaseModel):
|
||||
"""学文风:上传样本正文(前端可读文件转文本)+ 模式(首学 / 更新)。"""
|
||||
|
||||
samples: list[str] = Field(min_length=1)
|
||||
mode: Literal["create", "update"] = "create"
|
||||
|
||||
|
||||
class StyleLearnResponse(BaseModel):
|
||||
"""学文风受理:返回 job_id(长任务,走 `GET /jobs/{id}` 轮询)。"""
|
||||
|
||||
job_id: uuid.UUID
|
||||
|
||||
|
||||
class StyleFingerprintResponse(BaseModel):
|
||||
"""最新文风指纹(`GET /style`):完整 16 维 + 证据 + 版本(对齐 UX §6.9)。"""
|
||||
|
||||
dimensions: dict[str, Any] = Field(default_factory=dict)
|
||||
evidence: dict[str, Any] = Field(default_factory=dict)
|
||||
version: int
|
||||
|
||||
|
||||
class RefineRequest(BaseModel):
|
||||
"""回炉:重写选中段(可选改写指令)。"""
|
||||
|
||||
segment: str = Field(min_length=1)
|
||||
instruction: str | None = None
|
||||
|
||||
|
||||
class RefineResponse(BaseModel):
|
||||
"""回炉结果:原段 + 重写段(不写库,作者采纳经既有 draft 自动保存合入)。"""
|
||||
|
||||
original: str
|
||||
refined: str
|
||||
@@ -20,12 +20,24 @@ from ww_llm_gateway.adapters.base import Capabilities
|
||||
STUB_OWNER_ID = uuid.UUID(int=1)
|
||||
|
||||
|
||||
# 凭据认证类型(`provider_credentials.auth_type`,见 C2 扩 K1.1)。
|
||||
AUTH_TYPE_API_KEY = "api_key"
|
||||
AUTH_TYPE_OAUTH = "oauth"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredCredential:
|
||||
"""存储层视图:含密文,绝不出 API 边界(路由仅取 provider 并掩码)。"""
|
||||
"""存储层视图:含密文,绝不出 API 边界(路由仅取 provider 并掩码)。
|
||||
|
||||
一行二选一:`auth_type="api_key"` → `api_key_enc` 有值、`oauth_enc=None`;
|
||||
`auth_type="oauth"`(Kimi Code device-flow,K1.3)→ `oauth_enc` 有值、`api_key_enc=None`
|
||||
(持 Fernet 加密的 `{access_token,refresh_token,expires_at}` JSON 包)。
|
||||
"""
|
||||
|
||||
provider: str
|
||||
api_key_enc: bytes
|
||||
api_key_enc: bytes | None
|
||||
auth_type: str = AUTH_TYPE_API_KEY
|
||||
oauth_enc: bytes | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -51,6 +63,12 @@ class CredentialStore(Protocol):
|
||||
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
|
||||
) -> None: ...
|
||||
|
||||
async def upsert_oauth_credential(
|
||||
self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes
|
||||
) -> None: ...
|
||||
|
||||
async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool: ...
|
||||
|
||||
async def upsert_routing(self, routing: StoredRouting) -> None: ...
|
||||
|
||||
|
||||
@@ -72,7 +90,15 @@ class SqlCredentialStore:
|
||||
select(ProviderCredential).where(ProviderCredential.owner_id == owner_id)
|
||||
)
|
||||
).scalars()
|
||||
return [StoredCredential(provider=r.provider, api_key_enc=r.api_key_enc) for r in rows]
|
||||
return [
|
||||
StoredCredential(
|
||||
provider=r.provider,
|
||||
api_key_enc=r.api_key_enc,
|
||||
auth_type=r.auth_type,
|
||||
oauth_enc=r.oauth_enc,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
async def list_routing(self) -> list[StoredRouting]:
|
||||
rows = (await self._session.execute(select(TierRouting))).scalars()
|
||||
@@ -94,7 +120,12 @@ class SqlCredentialStore:
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return StoredCredential(provider=row.provider, api_key_enc=row.api_key_enc)
|
||||
return StoredCredential(
|
||||
provider=row.provider,
|
||||
api_key_enc=row.api_key_enc,
|
||||
auth_type=row.auth_type,
|
||||
oauth_enc=row.oauth_enc,
|
||||
)
|
||||
|
||||
async def upsert_credential(
|
||||
self, owner_id: uuid.UUID, provider: str, api_key_enc: bytes
|
||||
@@ -117,12 +148,68 @@ class SqlCredentialStore:
|
||||
project_id=None,
|
||||
provider=provider,
|
||||
api_key_enc=api_key_enc,
|
||||
auth_type=AUTH_TYPE_API_KEY,
|
||||
oauth_enc=None,
|
||||
)
|
||||
)
|
||||
else:
|
||||
existing.api_key_enc = api_key_enc
|
||||
existing.auth_type = AUTH_TYPE_API_KEY
|
||||
existing.oauth_enc = None
|
||||
await self._session.commit()
|
||||
|
||||
async def upsert_oauth_credential(
|
||||
self, owner_id: uuid.UUID, provider: str, oauth_enc: bytes
|
||||
) -> None:
|
||||
"""写/更新 OAuth 凭据行(Kimi Code device-flow,K1.3)。
|
||||
|
||||
`auth_type="oauth"`、`oauth_enc=<Fernet 加密 token 包>`、`api_key_enc=None`。
|
||||
显式 read-modify-write(同 `upsert_credential`:含可空 project_id 的唯一约束不能用
|
||||
PG `ON CONFLICT`,见 memory/gotchas)。明文 token 绝不进此层(已加密)。
|
||||
"""
|
||||
existing = (
|
||||
await self._session.execute(
|
||||
select(ProviderCredential).where(
|
||||
ProviderCredential.owner_id == owner_id,
|
||||
ProviderCredential.project_id.is_(None),
|
||||
ProviderCredential.provider == provider,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
self._session.add(
|
||||
ProviderCredential(
|
||||
owner_id=owner_id,
|
||||
project_id=None,
|
||||
provider=provider,
|
||||
api_key_enc=None,
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
oauth_enc=oauth_enc,
|
||||
)
|
||||
)
|
||||
else:
|
||||
existing.api_key_enc = None
|
||||
existing.auth_type = AUTH_TYPE_OAUTH
|
||||
existing.oauth_enc = oauth_enc
|
||||
await self._session.commit()
|
||||
|
||||
async def delete_credential(self, owner_id: uuid.UUID, provider: str) -> bool:
|
||||
"""删除凭据行(OAuth disconnect / 撤销)。返回是否删到行。"""
|
||||
existing = (
|
||||
await self._session.execute(
|
||||
select(ProviderCredential).where(
|
||||
ProviderCredential.owner_id == owner_id,
|
||||
ProviderCredential.project_id.is_(None),
|
||||
ProviderCredential.provider == provider,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
return False
|
||||
await self._session.delete(existing)
|
||||
await self._session.commit()
|
||||
return True
|
||||
|
||||
async def upsert_routing(self, routing: StoredRouting) -> None:
|
||||
existing = (
|
||||
await self._session.execute(
|
||||
|
||||
97
apps/api/ww_api/services/job_runner.py
Normal file
97
apps/api/ww_api/services/job_runner.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""通用长任务 runner(BackgroundTask 跑 `jobs` 表上的异步工作;ARCH §7.4)。
|
||||
|
||||
T4.3「学文风走 jobs」复用此基建:`POST /style` 立即写一行 `jobs(status=queued)` 返
|
||||
202 `{job_id}`,再经 FastAPI `BackgroundTasks` 登记 `run_job(...)` 跑真正的提取工作。
|
||||
|
||||
**独立 session 纪律**(同 `services/foreshadow_scan.run_overdue_scan` 先例 + memory/gotchas):
|
||||
BackgroundTask 在请求-response 发回、请求 session 关闭**之后**才跑——故 `run_job`
|
||||
**自建新 session**(经 `session_factory`),绝不复用请求 session。
|
||||
|
||||
`work: Callable[[AsyncSession], Awaitable[dict]]` 是业务逻辑缝(T4.3 部分应用「跑提取
|
||||
→ 写 style_fingerprint → 返回 result 摘要」)。`work` 拿到的 session 与 job 状态写同一
|
||||
session → 一次 `commit()` 一并落库(业务写 + job done 原子)。
|
||||
|
||||
可测性:`run_job` 经可注入 `session_factory`/`repo_factory` 缝——单测直接 `await` 它,
|
||||
注 fake session 工厂 + fake job repo + fake work(**不起后台线程、不连真 DB**),断言
|
||||
成功路置 done、异常路置 failed。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain.job_repo import JobView, SqlJobRepo
|
||||
|
||||
from ww_api.services.foreshadow_scan import SessionFactory
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
# 业务工作缝:拿 session 跑真正的长任务,返回写回 job.result 的摘要 dict。
|
||||
JobWork = Callable[[AsyncSession], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
class JobLifecycleRepo(Protocol):
|
||||
"""`run_job` 对 job repo 的**最小**依赖(仅生命周期三态写)——便于注入 fake。"""
|
||||
|
||||
async def set_running(self, job_id: uuid.UUID) -> JobView: ...
|
||||
|
||||
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView: ...
|
||||
|
||||
async def fail(self, job_id: uuid.UUID, error: str) -> JobView: ...
|
||||
|
||||
|
||||
# repo 工厂:从新 session 造 job repo。默认建 SQL 实现;测试注 fake(避免真连 DB)。
|
||||
JobRepoFactory = Callable[[AsyncSession], JobLifecycleRepo]
|
||||
|
||||
|
||||
def _default_repo_factory(session: AsyncSession) -> JobLifecycleRepo:
|
||||
return SqlJobRepo(session)
|
||||
|
||||
|
||||
async def run_job(
|
||||
session_factory: SessionFactory,
|
||||
job_id: uuid.UUID,
|
||||
work: JobWork,
|
||||
*,
|
||||
request_id: str | None = None,
|
||||
repo_factory: JobRepoFactory = _default_repo_factory,
|
||||
) -> None:
|
||||
"""跑一个长任务:新建独立 session → set_running → await work → complete/fail → commit。
|
||||
|
||||
成功:`complete(job_id, result)`(status=done, progress=100, result=work 返回值)后 commit。
|
||||
异常:回滚 work 的部分写 → 新 session 里 `fail(job_id, str(exc))` → commit(job 失败可见)。
|
||||
任何异常都被吞(后台任务边界,不冒泡崩进程);失败置态本身再炸只记日志。
|
||||
`session_factory`/`repo_factory` 是可注入缝:测试直接 await、注 fake,绝不联网/起线程。
|
||||
"""
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
repo = repo_factory(session)
|
||||
await repo.set_running(job_id)
|
||||
result = await work(session)
|
||||
await repo.complete(job_id, result)
|
||||
await session.commit()
|
||||
log.info("job_done", job_id=str(job_id), request_id=request_id)
|
||||
except Exception as exc: # noqa: BLE001 — 后台任务边界:记错误 + 置 job failed,不冒泡。
|
||||
log.error("job_failed", job_id=str(job_id), request_id=request_id, error=str(exc))
|
||||
await _mark_failed(session_factory, job_id, str(exc), repo_factory, request_id)
|
||||
|
||||
|
||||
async def _mark_failed(
|
||||
session_factory: SessionFactory,
|
||||
job_id: uuid.UUID,
|
||||
error: str,
|
||||
repo_factory: JobRepoFactory,
|
||||
request_id: str | None,
|
||||
) -> None:
|
||||
"""在一个**全新** session 里把 job 置 failed(前一 session 的事务已因异常作废)。"""
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
repo = repo_factory(session)
|
||||
await repo.fail(job_id, error)
|
||||
await session.commit()
|
||||
except Exception as exc: # noqa: BLE001 — 置失败态本身再炸只记日志,不冒泡。
|
||||
log.error("job_fail_mark_failed", job_id=str(job_id), request_id=request_id, error=str(exc))
|
||||
240
apps/api/ww_api/services/kimi_oauth.py
Normal file
240
apps/api/ww_api/services/kimi_oauth.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""Kimi Code OAuth device-flow 客户端(K1.3 / PROGRESS K1)。
|
||||
|
||||
Kimi 订阅 plan 走 OAuth 2.0 **device authorization flow**(RFC 8628):
|
||||
|
||||
1. `start_device_authorization` → `POST .../device_authorization`,拿 `device_code` +
|
||||
`user_code` + `verification_uri`(用户在浏览器授权)+ 轮询 `interval`/过期 `expires_in`。
|
||||
2. `poll_token`(一次尝试,调用方按 `interval` 循环)→ `POST .../token`
|
||||
(grant=device_code);`authorization_pending` → 继续轮询、`slow_down` → 增大间隔、
|
||||
`expired_token`/`access_denied` → 停止失败;成功 → access/refresh token。
|
||||
3. `refresh` → 同 token 端点(grant=refresh_token),换新的 access/refresh token。
|
||||
|
||||
**httpx 注入**:所有 HTTP 经注入的 `AsyncHttpClient` Protocol(= `httpx.AsyncClient`
|
||||
的 `.post` 子集)——测试注 fake,**绝不联网**。
|
||||
|
||||
**token 不落明文**:`TokenSet` 序列化为 JSON 串经 Fernet 加密入 `provider_credentials.oauth_enc`
|
||||
(`encrypt_oauth_bundle`/`decrypt_oauth_bundle`),明文 token 绝不进日志/响应/job 结果。
|
||||
|
||||
研究确认(对照 `github.com/ooojustin/opencode-kimi` `constants.ts` + `picassio/pi-kimi-coder`):
|
||||
- client_id `17e5f671-d194-4dfb-9706-5516cb48c098`(env `KIMI_CLIENT_ID` 可覆盖)。
|
||||
- device authorization **带 `scope=kimi-code`**(coding-agent OAuth scope):opencode-kimi
|
||||
`constants.ts` 发送之;kimi-cli v1.41.0 已不发但服务端仍接受。实测省略 scope 拿到的 token
|
||||
缺 coding entitlement,调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`,故重新带上。
|
||||
- scope 只放在 device_authorization 请求上;token 交换/刷新不带 scope(OAuth device flow 惯例)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.security.credentials import decrypt_api_key, encrypt_api_key
|
||||
|
||||
#: Kimi OAuth 端点(device authorization + token)。
|
||||
KIMI_AUTH_BASE_URL = "https://auth.kimi.com/api/oauth"
|
||||
DEVICE_AUTHORIZATION_URL = f"{KIMI_AUTH_BASE_URL}/device_authorization"
|
||||
TOKEN_URL = f"{KIMI_AUTH_BASE_URL}/token"
|
||||
|
||||
#: 默认 client_id(kimi-cli 公开常量;env `KIMI_CLIENT_ID` 可覆盖)。
|
||||
DEFAULT_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"
|
||||
ENV_CLIENT_ID = "KIMI_CLIENT_ID"
|
||||
|
||||
GRANT_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
GRANT_REFRESH_TOKEN = "refresh_token"
|
||||
|
||||
#: coding-agent OAuth scope(device authorization 专用;缺它 token 无 coding entitlement)。
|
||||
KIMI_CODE_SCOPE = "kimi-code"
|
||||
|
||||
#: device flow 默认轮询间隔(秒)——服务端未给 `interval` 时的兜底。
|
||||
DEFAULT_POLL_INTERVAL = 5
|
||||
|
||||
#: token 刷新触发缓冲(秒):剩余寿命低于 max(300, 0.5*expires_in) 即刷新。
|
||||
MIN_REFRESH_BUFFER_SECONDS = 300
|
||||
|
||||
|
||||
def client_id() -> str:
|
||||
"""当前 OAuth client_id(env `KIMI_CLIENT_ID` 优先,否则公开默认值)。"""
|
||||
return os.environ.get(ENV_CLIENT_ID) or DEFAULT_CLIENT_ID
|
||||
|
||||
|
||||
class AsyncHttpClient(Protocol):
|
||||
"""`run_job`/服务对 HTTP 客户端的**最小**依赖(= `httpx.AsyncClient.post` 子集)。
|
||||
|
||||
便于测试注 fake(绝不联网)。运行时传 `httpx.AsyncClient`。
|
||||
"""
|
||||
|
||||
async def post(self, url: str, *, data: dict[str, str]) -> HttpResponse: ...
|
||||
|
||||
|
||||
class HttpResponse(Protocol):
|
||||
"""HTTP 响应的最小读接口(`httpx.Response` 满足之)。"""
|
||||
|
||||
status_code: int
|
||||
|
||||
def json(self) -> Any: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceAuth:
|
||||
"""device authorization 响应(用户面:展示 user_code + 打开 verification_uri)。"""
|
||||
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_uri: str
|
||||
verification_uri_complete: str | None
|
||||
expires_in: int
|
||||
interval: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TokenSet:
|
||||
"""OAuth token 三元组(access 短期 / refresh 长期 / 服务端驱动过期时刻 UTC)。"""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class AuthorizationPending(Exception):
|
||||
"""device flow 轮询:用户尚未授权(继续轮询)。"""
|
||||
|
||||
|
||||
class SlowDown(Exception):
|
||||
"""device flow 轮询:轮询过快(增大 interval 后继续)。"""
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _expires_at(expires_in: int) -> datetime:
|
||||
return _now() + timedelta(seconds=max(0, expires_in))
|
||||
|
||||
|
||||
def needs_refresh(token: TokenSet, *, now: datetime | None = None) -> bool:
|
||||
"""判定 access token 是否临近过期(剩余寿命 < `MIN_REFRESH_BUFFER_SECONDS`)。
|
||||
|
||||
建网关时按需刷新(§token 刷新启发式);过期时刻已是服务端驱动的绝对时刻,故只需
|
||||
与缓冲比较(无需原始 expires_in:缓冲固定 300s,对 ~15min access 足够)。
|
||||
"""
|
||||
current = now or _now()
|
||||
remaining = (token.expires_at - current).total_seconds()
|
||||
return remaining < MIN_REFRESH_BUFFER_SECONDS
|
||||
|
||||
|
||||
async def start_device_authorization(http: AsyncHttpClient) -> DeviceAuth:
|
||||
"""发起 device authorization(带 `scope=kimi-code` 以获取 coding entitlement)。"""
|
||||
resp = await http.post(
|
||||
DEVICE_AUTHORIZATION_URL,
|
||||
data={"client_id": client_id(), "scope": KIMI_CODE_SCOPE},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
"Kimi 设备授权请求失败",
|
||||
{"status": resp.status_code},
|
||||
)
|
||||
body = resp.json()
|
||||
return DeviceAuth(
|
||||
device_code=str(body["device_code"]),
|
||||
user_code=str(body["user_code"]),
|
||||
verification_uri=str(body["verification_uri"]),
|
||||
verification_uri_complete=(
|
||||
str(body["verification_uri_complete"])
|
||||
if body.get("verification_uri_complete")
|
||||
else None
|
||||
),
|
||||
expires_in=int(body.get("expires_in", 0)),
|
||||
interval=int(body.get("interval", DEFAULT_POLL_INTERVAL)),
|
||||
)
|
||||
|
||||
|
||||
def _token_set_from_body(body: dict[str, Any]) -> TokenSet:
|
||||
return TokenSet(
|
||||
access_token=str(body["access_token"]),
|
||||
refresh_token=str(body["refresh_token"]),
|
||||
expires_at=_expires_at(int(body.get("expires_in", 0))),
|
||||
)
|
||||
|
||||
|
||||
async def poll_token(http: AsyncHttpClient, device_code: str) -> TokenSet:
|
||||
"""轮询一次 token 端点(调用方按 interval 循环)。
|
||||
|
||||
`authorization_pending` → 抛 `AuthorizationPending`(继续轮询);
|
||||
`slow_down` → 抛 `SlowDown`(增大 interval);
|
||||
`expired_token`/`access_denied`/其它 → 抛 `AppError`(停止失败);
|
||||
成功 → `TokenSet`。
|
||||
"""
|
||||
resp = await http.post(
|
||||
TOKEN_URL,
|
||||
data={
|
||||
"grant_type": GRANT_DEVICE_CODE,
|
||||
"device_code": device_code,
|
||||
"client_id": client_id(),
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
if resp.status_code >= 400 or body.get("error"):
|
||||
error = str(body.get("error", "unknown_error"))
|
||||
if error == "authorization_pending":
|
||||
raise AuthorizationPending
|
||||
if error == "slow_down":
|
||||
raise SlowDown
|
||||
# expired_token / access_denied / 其它 → 终止失败。
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
f"Kimi 设备授权失败:{error}",
|
||||
{"error": error},
|
||||
)
|
||||
return _token_set_from_body(body)
|
||||
|
||||
|
||||
async def refresh(http: AsyncHttpClient, refresh_token: str) -> TokenSet:
|
||||
"""用 refresh_token 换新 token 组(access 临近过期时建网关触发)。"""
|
||||
resp = await http.post(
|
||||
TOKEN_URL,
|
||||
data={
|
||||
"grant_type": GRANT_REFRESH_TOKEN,
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": client_id(),
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
if resp.status_code >= 400 or body.get("error"):
|
||||
error = str(body.get("error", "unknown_error"))
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
f"Kimi token 刷新失败:{error}",
|
||||
{"error": error},
|
||||
)
|
||||
return _token_set_from_body(body)
|
||||
|
||||
|
||||
def encrypt_oauth_bundle(token: TokenSet, *, key: str) -> bytes:
|
||||
"""把 `TokenSet` 序列化为 JSON 串并 Fernet 加密为 `oauth_enc` 密文。
|
||||
|
||||
JSON 形 `{access_token, refresh_token, expires_at(ISO8601)}`——明文 token 绝不出此函数。
|
||||
"""
|
||||
bundle = json.dumps(
|
||||
{
|
||||
"access_token": token.access_token,
|
||||
"refresh_token": token.refresh_token,
|
||||
"expires_at": token.expires_at.isoformat(),
|
||||
}
|
||||
)
|
||||
return encrypt_api_key(bundle, key=key)
|
||||
|
||||
|
||||
def decrypt_oauth_bundle(blob: bytes, *, key: str) -> TokenSet:
|
||||
"""解密 `oauth_enc` 密文回 `TokenSet`(reuse Fernet helper,工作在 str 上)。"""
|
||||
bundle = json.loads(decrypt_api_key(blob, key=key))
|
||||
return TokenSet(
|
||||
access_token=str(bundle["access_token"]),
|
||||
refresh_token=str(bundle["refresh_token"]),
|
||||
expires_at=datetime.fromisoformat(str(bundle["expires_at"])),
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -34,6 +34,10 @@ _PROVIDER_BASE_URLS: dict[str, str] = {
|
||||
"qwen": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"glm": "https://open.bigmodel.cn/api/paas/v4",
|
||||
"openai": "https://api.openai.com/v1",
|
||||
# Kimi 订阅 plan(OAuth device-flow,K1.3):coding 端点(OpenAI 兼容 + 伪造头)。
|
||||
"kimi-code": "https://api.kimi.com/coding/v1",
|
||||
# Kimi 订阅 plan(静态 Console Key,ToS 合规):同一 coding 端点,纯 bearer 无伪造头。
|
||||
"kimi-code-key": "https://api.kimi.com/coding/v1",
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +72,13 @@ class GatewayProviderProbe:
|
||||
f"未知提供商 {provider}",
|
||||
{"provider": provider},
|
||||
)
|
||||
if cred.api_key_enc is None:
|
||||
# api_key 探测不支持 OAuth 凭据(无 api_key 密文)——OAuth provider 走专属端点。
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"provider {provider} 为 OAuth 凭据,不支持 api_key 连接测试",
|
||||
{"provider": provider},
|
||||
)
|
||||
try:
|
||||
api_key = decrypt_api_key(cred.api_key_enc, key=self._enc_key)
|
||||
except CredentialKeyError as exc:
|
||||
|
||||
Reference in New Issue
Block a user