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