Import Human Voice Rewrite Demo (PRD v1.1) + Dockerfile for Coolify
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user