204 lines
7.4 KiB
Python
204 lines
7.4 KiB
Python
"""Human Voice Rewrite Demo backend (PRD v1.1).
|
||
|
||
Serves the static demo page and domain endpoints that run the real
|
||
OpenRouter pipeline. Run via ./local_start.sh.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from typing import Any, Callable
|
||
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.responses import FileResponse
|
||
|
||
from evidence import sanitize_diagnosis
|
||
from llm import LLMError, LlmClient
|
||
from prompts import (
|
||
build_analyze_prompt,
|
||
build_diagnose_prompt,
|
||
build_recheck_prompt,
|
||
build_reference_prompt,
|
||
build_scaffold_prompt,
|
||
normalize_diagnose_payload,
|
||
)
|
||
from schemas import (
|
||
AnalyzeRequest,
|
||
AnalyzeResponse,
|
||
DiagnoseRequest,
|
||
DiagnoseResponse,
|
||
GlobalChecks,
|
||
RecheckRequest,
|
||
RecheckResponse,
|
||
ReferenceRequest,
|
||
ReferenceResponse,
|
||
RevisionTarget,
|
||
ScaffoldRequest,
|
||
ScaffoldResponse,
|
||
)
|
||
|
||
app = FastAPI(title="Human Voice Rewrite Demo", version="1.1.0")
|
||
|
||
client: LlmClient | None = None
|
||
HERE = Path(__file__).resolve().parent
|
||
|
||
|
||
def get_client() -> LlmClient:
|
||
global client
|
||
if client is None:
|
||
client = LlmClient()
|
||
return client
|
||
|
||
|
||
def run_json(phase: str, system: str, user: str, check: Callable[[dict[str, Any]], str | None] | None = None) -> dict:
|
||
try:
|
||
return get_client().complete_json(system, user, validate=check)
|
||
except LLMError as exc:
|
||
raise HTTPException(status_code=502, detail=f"{phase}失败:{exc}") from exc
|
||
|
||
|
||
def _count_check(expected: int, key: str) -> Callable[[dict[str, Any]], str | None]:
|
||
"""返回语义校验器:LLM 输出里按段组织的数组条目数必须与输入段数一致。
|
||
|
||
不一致时返回一行中文说明(触发 complete_json 重试一次;两次都失败才变
|
||
502 单行错误)——真因:真实 LLM 偶发返回合法 JSON 但段落条目不全,
|
||
此前无数量校验被静默接受,AI 初审出现"4 段标题只显示 1 段"(用户实测)。"""
|
||
|
||
def check(data: dict[str, Any]) -> str | None:
|
||
got = len(data.get(key) or [])
|
||
if got != expected:
|
||
return f"结果段落数与输入不一致(输入 {expected} 段,返回 {got} 段)"
|
||
return None
|
||
|
||
return check
|
||
|
||
|
||
def _friendly_validation(exc: Exception) -> str:
|
||
"""把 pydantic ValidationError 压缩成一行用户可读摘要(PRD §11 失败兜底:
|
||
返回用户可读错误,不把完整校验堆栈甩给页面)。"""
|
||
errs = getattr(exc, "errors", lambda: [])()
|
||
if not errs:
|
||
return f"结果格式不符合预期:{exc}"
|
||
parts = []
|
||
for e in errs[:2]:
|
||
loc = " → ".join(str(x) for x in e.get("loc", []))
|
||
parts.append(f"{loc}:{e.get('msg', '')}" if loc else e.get("msg", ""))
|
||
more = f"(共 {len(errs)} 处)" if len(errs) > 2 else ""
|
||
return "结果格式不符合预期:" + ";".join(parts) + more
|
||
|
||
|
||
@app.get("/api/health")
|
||
def health() -> dict:
|
||
return {"status": "ok"}
|
||
|
||
|
||
@app.post("/api/analyze", response_model=AnalyzeResponse)
|
||
def analyze(req: AnalyzeRequest) -> AnalyzeResponse:
|
||
if not any(p.strip() for p in req.paragraphs):
|
||
raise HTTPException(status_code=422, detail="文本为空,无法开始分析")
|
||
system, user = build_analyze_prompt(req.prompt, req.word_limit, req.paragraphs, req.constraints)
|
||
data = run_json("分析", system, user, check=_count_check(len(req.paragraphs), "paragraphs"))
|
||
try:
|
||
return AnalyzeResponse.model_validate(data)
|
||
except Exception as exc: # pydantic ValidationError etc.
|
||
raise HTTPException(status_code=502, detail=f"分析{_friendly_validation(exc)}") from exc
|
||
|
||
|
||
@app.post("/api/diagnose", response_model=DiagnoseResponse)
|
||
def diagnose(req: DiagnoseRequest) -> DiagnoseResponse:
|
||
system, user = build_diagnose_prompt(
|
||
req.prompt,
|
||
req.word_limit,
|
||
req.paragraphs,
|
||
req.confirmed_anchors,
|
||
req.global_constraints,
|
||
req.paragraph_constraints,
|
||
req.initial_analysis,
|
||
)
|
||
data = run_json("诊断", system, user, check=_count_check(len(req.paragraphs), "paragraph_briefs"))
|
||
data = normalize_diagnose_payload(data)
|
||
try:
|
||
resp = DiagnoseResponse.model_validate(data)
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=502, detail=f"诊断{_friendly_validation(exc)}") from exc
|
||
return sanitize_diagnosis(resp, req.paragraphs)
|
||
|
||
|
||
@app.post("/api/recheck", response_model=RecheckResponse)
|
||
def recheck(req: RecheckRequest) -> RecheckResponse:
|
||
if len(req.original_paragraphs) != len(req.rewrite_paragraphs):
|
||
raise HTTPException(status_code=422, detail="原文段数与改写段数不一致")
|
||
if any(not p.strip() for p in req.rewrite_paragraphs):
|
||
raise HTTPException(status_code=422, detail="存在空段:全部段落完成改写后才能提交复检")
|
||
system, user = build_recheck_prompt(req)
|
||
data = run_json("复检", system, user)
|
||
if isinstance(data.get("status"), str):
|
||
data["status"] = "pass" if data["status"] == "pass" else "revision_required"
|
||
if not data.get("checked_rewrite_version"):
|
||
data["checked_rewrite_version"] = req.rewrite_version or ""
|
||
try:
|
||
resp = RecheckResponse.model_validate(data)
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=502, detail=f"复检{_friendly_validation(exc)}") from exc
|
||
if resp.status == "revision_required" and not resp.revision_targets:
|
||
resp = RecheckResponse(
|
||
status="revision_required",
|
||
checked_rewrite_version=resp.checked_rewrite_version or req.rewrite_version,
|
||
global_checks=resp.global_checks or GlobalChecks(),
|
||
revision_targets=[
|
||
RevisionTarget(
|
||
paragraph_id="p1",
|
||
blocking_issue="模型判定需要返工但未指明段落",
|
||
single_revision_goal="重读当前段改写并修正最明显的问题",
|
||
)
|
||
],
|
||
)
|
||
return resp
|
||
|
||
|
||
@app.post("/api/scaffold", response_model=ScaffoldResponse)
|
||
def scaffold(req: ScaffoldRequest) -> ScaffoldResponse:
|
||
system, user = build_scaffold_prompt(
|
||
req.paragraph_id,
|
||
req.original_text,
|
||
req.semantic_anchor,
|
||
req.rewrite_goal,
|
||
req.global_constraints,
|
||
req.paragraph_constraints,
|
||
)
|
||
data = run_json("写作起点", system, user)
|
||
try:
|
||
resp = ScaffoldResponse.model_validate(data)
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=502, detail=f"写作起点{_friendly_validation(exc)}") from exc
|
||
resp.paragraph_id = resp.paragraph_id or req.paragraph_id
|
||
return resp
|
||
|
||
|
||
@app.post("/api/reference", response_model=ReferenceResponse)
|
||
def reference(req: ReferenceRequest) -> ReferenceResponse:
|
||
goal = req.rewrite_goal or req.primary_goal
|
||
system, user = build_reference_prompt(
|
||
req.paragraph_id,
|
||
req.original_text,
|
||
req.semantic_anchor,
|
||
goal,
|
||
req.global_constraints,
|
||
req.paragraph_constraints,
|
||
)
|
||
data = run_json("参考片段", system, user)
|
||
try:
|
||
resp = ReferenceResponse.model_validate(data)
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=502, detail=f"参考片段{_friendly_validation(exc)}") from exc
|
||
snippet = resp.reference_snippet or resp.starter
|
||
resp.paragraph_id = resp.paragraph_id or req.paragraph_id
|
||
resp.starter = snippet
|
||
resp.reference_snippet = snippet
|
||
return resp
|
||
|
||
|
||
@app.get("/")
|
||
def index() -> FileResponse:
|
||
return FileResponse(HERE / "index.html", media_type="text/html")
|