P0-1 SqlCredentialStore/save_draft 由自提交改 flush,端点/服务统一 commit (新增 CredentialStore.commit() 统一提交点;token 刷新落库显式提交); 补多凭据一请求中途失败整体回滚集成测试。 P0-2 启动校验 _fernet(enc_key) 快速失败 + catch-all Exception → ErrorEnvelope; credential_enc_key 改 SecretStr。 P0-3 run_job 异常分类:AppError 存 code+message,其余存通用文案不泄 str(exc)。 P0-4 评审/正文 SSE 失败先发 error 事件,尾部 commit 包 try/except。 P1-4 max_version 加 FOR UPDATE 行锁消除 TOCTOU。 P1-5 scan_overdue 谓词下推 + 批量 UPDATE RETURNING。 P1-10 移除 OAuth user_code 日志。 P2 provider_deps 改调网关 build_adapter;accept_service Committable Protocol; CORS 白名单收窄;request_id 安全字符集白名单;stdlib 日志接管;读端点 404 校验; httpx timeout;测试用合法 Fernet key;类型化响应模型(JobResponse/DimensionEntry/ ReviewConflictView/selling_points)+路由 ErrorEnvelope responses(供 codegen)。
241 lines
9.7 KiB
Python
241 lines
9.7 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, ErrorEnvelope
|
||
|
||
from ww_api.logging_config import get_logger
|
||
from ww_api.schemas.style import (
|
||
DimensionEntry,
|
||
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)
|
||
|
||
|
||
def _merge_dimensions(dimensions: dict[str, Any], evidence: dict[str, Any]) -> list[DimensionEntry]:
|
||
"""把 DB 两列并行 dict 合并为 `list[DimensionEntry]`(按维度名稳定排序,便于前端展示)。"""
|
||
return [
|
||
DimensionEntry(
|
||
name=name,
|
||
value=str(dimensions[name]),
|
||
evidence=[str(e) for e in (evidence.get(name) or [])],
|
||
)
|
||
for name in sorted(dimensions)
|
||
]
|
||
|
||
|
||
@router.get(
|
||
"/{project_id}/style",
|
||
responses={404: {"model": ErrorEnvelope, "description": "项目或指纹不存在"}},
|
||
)
|
||
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=_merge_dimensions(latest.dimensions, 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)
|