Import Human Voice Rewrite Demo (PRD v1.1) + Dockerfile for Coolify

This commit is contained in:
Jeamee
2026-08-24 16:49:57 +08:00
commit d6f70b02f4
16 changed files with 3580 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
"""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")