把对话页从固定状态机换成 skill 驱动的 Agent:顾问说什么都原样发给 agent, 由 SKILL.md + 工具决定该聊还是该调工具。工作台(workbenchView)未改动。 后端 - llm.py:新增 stream_chat()(流式 + tools),并带 reasoning 端点自动降级 (gemini-3.7-flash 拒绝 effort:none,首次 400 后锁定重试) - agent.py:最小对话底座内核——skill 注入 + 工具循环 + SSE 事件 - tools.py:8 个工具包住既有能力(analyze/diagnose/scaffold/reference/ translate/recheck/record_note/confirm),延迟 import main 复用已验证的 endpoint 处理器与测试接缝,prompts.py/schemas.py/evidence.py 未改一行 - main.py:POST /api/chat/stream(SSE) skill - skills/hvr-rewrite/SKILL.md:分诊规则、首轮盘点、AI 味清单对齐 blader/humanizer 的 A–E 分类(含强度校准与反误报)、诚实性硬约束 前端 - 流式正文 + 工具卡片 + 选项按钮;工具 payload 直接喂既有渲染函数 (renderUnderstanding/renderDiagnosis/renderRecheck),没有第二套 UI - 删除随 agent 化失效的 runDiagnose / applyUnderstandingCorrection 死代码 验证(真实模型,非 mock) - 65 单测全绿(test_agent.py 30 + test_demo.py 35) - 逐轮实跑:盘点 → 确认 → 诊断 → 复检,SSE 事件序列与 payload kind 均符合契约 - 前端 readSSE 用真实响应字节按 7/64/全量三种切块回放,事件序列一致 - 实测修掉两个只在真跑时暴露的问题:模型调完分析直接调确认工具导致正文为空 (SKILL.md 补「正文先行」硬规则);伪标题 `**N. 标题**` 让列表判定失败、 短横线漏成字面字符(前端改逐行分组渲染) 未验证:浏览器人工走查(无浏览器自动化环境),仅到「真实 HTTP + 真实字节回放」这一层。
306 lines
12 KiB
Python
306 lines
12 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
|
||
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Iterator
|
||
|
||
from fastapi import FastAPI, HTTPException
|
||
from fastapi.responses import FileResponse, StreamingResponse
|
||
from pydantic import BaseModel
|
||
|
||
import agent
|
||
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,
|
||
build_translate_prompt,
|
||
build_translate_sentences_prompt,
|
||
normalize_diagnose_payload,
|
||
)
|
||
from schemas import (
|
||
AnalyzeRequest,
|
||
AnalyzeResponse,
|
||
DiagnoseRequest,
|
||
DiagnoseResponse,
|
||
GlobalChecks,
|
||
RecheckRequest,
|
||
RecheckResponse,
|
||
ReferenceRequest,
|
||
ReferenceResponse,
|
||
RevisionTarget,
|
||
ScaffoldRequest,
|
||
ScaffoldResponse,
|
||
SentencePair,
|
||
TranslateRequest,
|
||
TranslateResponse,
|
||
)
|
||
|
||
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.post("/api/translate", response_model=TranslateResponse)
|
||
def translate(req: TranslateRequest) -> TranslateResponse:
|
||
"""体验优化:中文对照翻译。
|
||
- text 整段模式:改写区「译成中文对照」(自查改写后原意/细节是否保留)
|
||
- sentences 逐句模式:原文区「原文中文对照」(前端拆分句子,逐句对位,
|
||
让「需要改写的高亮句」在中文对照里能同色找到)。"""
|
||
if req.sentences:
|
||
return _translate_sentences(req)
|
||
if not req.text.strip():
|
||
raise HTTPException(status_code=422, detail="没有可翻译的文本")
|
||
system, user = build_translate_prompt(req.text)
|
||
data = run_json("翻译", system, user)
|
||
translation = data.get("translation")
|
||
if isinstance(translation, list): # 模型偶发把字符串字段返回成数组
|
||
translation = "".join(str(x) for x in translation)
|
||
resp = TranslateResponse(paragraph_id=req.paragraph_id, translation=str(translation or "").strip())
|
||
if not resp.translation:
|
||
raise HTTPException(status_code=502, detail="翻译结果为空,请重试")
|
||
return resp
|
||
|
||
|
||
def _translate_sentences(req: TranslateRequest) -> TranslateResponse:
|
||
clean = [s for s in (x.strip() for x in req.sentences) if s]
|
||
if not clean:
|
||
raise HTTPException(status_code=422, detail="没有可翻译的句子")
|
||
system, user = build_translate_sentences_prompt(clean)
|
||
# 句子数量校验复用 _count_check:不一致自动重试一次(对位可靠性依赖等长同序)
|
||
data = run_json("翻译", system, user, check=_count_check(len(clean), "sentences"))
|
||
items = data.get("sentences") or []
|
||
pairs: list[SentencePair] = []
|
||
for i, en in enumerate(clean):
|
||
zh = ""
|
||
if i < len(items) and isinstance(items[i], dict):
|
||
value = items[i].get("zh")
|
||
if isinstance(value, list): # 模型偶发把字符串字段返回成数组
|
||
value = "".join(str(x) for x in value)
|
||
zh = str(value or "").strip()
|
||
pairs.append(SentencePair(en=en, zh=zh))
|
||
if not any(p.zh for p in pairs):
|
||
raise HTTPException(status_code=502, detail="翻译结果为空,请重试")
|
||
return TranslateResponse(paragraph_id=req.paragraph_id, sentences=pairs)
|
||
|
||
|
||
class ChatMessage(BaseModel):
|
||
"""对话页的一轮输入。
|
||
|
||
业务上下文(原文/改写稿/顾问要求)每轮重发:顾问可能刚在工作台改完一段,
|
||
服务端不能假设自己手上那份还是最新的。session 里保留的是分析/诊断结果。"""
|
||
|
||
session_id: str | None = None
|
||
message: str = ""
|
||
prompt: str = ""
|
||
word_limit: int | None = None
|
||
paragraphs: list[str] = []
|
||
constraints: list[str] = []
|
||
rewrite_paragraphs: list[str] = []
|
||
confirmed_anchors: list[str] = []
|
||
paragraph_constraints: dict[str, list[str]] = {}
|
||
# 只有新会话的第一轮会带:页面刷新/进程重启后服务端手里没有已完成的结果,
|
||
# 缺了它们复检会静默降级(少 Pattern 对照、锚点为空)。老会话重发是纯浪费。
|
||
analysis: dict | None = None
|
||
diagnosis: dict | None = None
|
||
|
||
|
||
def _sse(name: str, data: dict[str, Any]) -> str:
|
||
return f"event: {name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||
|
||
|
||
@app.post("/api/chat/stream")
|
||
def chat_stream(req: ChatMessage) -> StreamingResponse:
|
||
"""对话页唯一入口(SSE)。事件契约见 docs/Agent化改造方案_20260911.md §四。
|
||
|
||
用同步生成器:StreamingResponse 会把它丢进线程池,与本文件其余同步端点一致。
|
||
事件里的异常一律翻成 error 帧而不是 500 —— 流已经开了,HTTP 状态码改不了,
|
||
前端只能靠 error 帧知道失败。"""
|
||
sess = agent.get_session(req.session_id, req.model_dump())
|
||
|
||
def gen() -> Iterator[str]:
|
||
try:
|
||
for ev in agent.run_agent_stream(sess, req.message, get_client()):
|
||
yield _sse(ev["event"], ev["data"])
|
||
except LLMError as exc:
|
||
yield _sse("error", {"code": "llm_error", "msg": str(exc)})
|
||
except Exception as exc: # 兜底:不让未预期异常变成半截流
|
||
yield _sse("error", {"code": "internal", "msg": f"{exc.__class__.__name__}:{exc}"})
|
||
|
||
return StreamingResponse(
|
||
gen(),
|
||
media_type="text/event-stream",
|
||
# no-store + X-Accel-Buffering:中间层缓冲会把流攒成一坨再吐,流式就白做了
|
||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
||
)
|
||
|
||
|
||
@app.get("/")
|
||
def index() -> FileResponse:
|
||
return FileResponse(HERE / "index.html", media_type="text/html")
|