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>
226 lines
9.1 KiB
Python
226 lines
9.1 KiB
Python
"""文风端点:学文风(异步长任务)+ 回炉 + 最新指纹读取
|
||
(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)
|