Files
LuminousRuoxi 94a26227c1 feat: 对话层 Agent 化(对话底座 + 自研 skill)
把对话页从固定状态机换成 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 + 真实字节回放」这一层。
2026-09-11 10:25:12 +08:00

306 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")