Files
writer-work-flow/apps/api/ww_api/routers/style.py
Yaojia Wang 345cc73965 fix(txn+security): 仓储改 flush + 启动校验/兜底 + job.error 脱敏 + SSE 异常硬化
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)。
2026-06-21 19:32:24 +02:00

241 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""文风端点:学文风(异步长任务)+ 回炉 + 最新指纹读取
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}`:学文风走 jobsM4-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, # 不变量 #2writer 档,不传 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)