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:
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)
|
||||
Reference in New Issue
Block a user