Files
human-voice-rewrite-demo/tools.py
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

564 lines
23 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.
"""对话层的工具注册表。
设计取舍(Why):
- 工具 = 「JSON-Schema 声明 + 一个 Python 函数」,与 prodream dreami engine 的
工具机制同形。LLM 只看到 schema,内核负责把调用路由到函数。
- 工具实现**不重写** analyze/diagnose/recheck 等能力,而是转调 main.py 里已有的
endpoint 处理器——那四个能力是已验证资产(prompt 约束 + 数量校验 + 重试兜底
全在里面),重写一份就是给自己留一个「改一条漏两条」的口子。
- 转调用的是延迟 import(函数内 `from main import ...`):main.py 在模块顶层
import 本模块,顶层再反向 import 会造成循环。延迟到调用时 main 已完全加载。
**这个接缝同时保证了测试打桩点唯一**——test_demo.py 一律 monkeypatch
`main.get_client`,工具走同一条路才不会绕过回归网。
- 工具给 LLM 的结果(summary)与给前端的结果(payload)分开:诊断 JSON 有 2.5k
tokens,塞回对话历史会滚雪球;前端要渲染的原始结构又不能省。summary 是紧凑
中文摘要(几十字),payload 原样回传。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from fastapi import HTTPException
from schemas import (
AnalyzeRequest,
AnalyzeResponse,
DiagnoseRequest,
DiagnoseResponse,
RecheckRequest,
ReferenceRequest,
ScaffoldRequest,
TranslateRequest,
)
@dataclass
class ToolContext:
"""一次会话的业务上下文。原文由前端在建会话时给一次,
之后所有工具从这里取——不让 LLM 在每轮参数里重抄整篇文书。"""
prompt: str = ""
word_limit: int | None = None
paragraphs: list[str] = field(default_factory=list)
constraints: list[str] = field(default_factory=list)
rewrite_paragraphs: list[str] = field(default_factory=list)
# 顾问确认/纠正后的语义锚点(与 paragraphs 等长,非空条目覆盖模型初判)
confirmed_anchors: list[str] = field(default_factory=list)
# 顾问分段要求:pid → 要求列表。优先级最高(PRD §10.1),必须透传到诊断与复检
paragraph_constraints: dict[str, list[str]] = field(default_factory=dict)
# 工具链累积的中间产物(dict 形态的 pydantic dump),供后续工具与前端复用
analysis: dict[str, Any] | None = None
diagnosis: dict[str, Any] | None = None
def paragraph(self, paragraph_id: str) -> str:
idx = _pid_index(paragraph_id)
if idx is None or not (0 <= idx < len(self.paragraphs)):
return ""
return self.paragraphs[idx]
def anchor(self, paragraph_id: str) -> str:
"""语义锚点:优先用理解阶段产出的,没有再退回顾问确认值。"""
idx = _pid_index(paragraph_id)
if idx is None:
return ""
brief = _brief(self.diagnosis, f"p{idx + 1}")
if brief and str(brief.get("confirmed_meaning") or "").strip():
return str(brief["confirmed_meaning"]).strip()
if idx < len(self.paragraphs):
paras = (self.analysis or {}).get("paragraphs") or []
if idx < len(paras) and isinstance(paras[idx], dict):
return str(paras[idx].get("semantic_anchor") or "").strip()
return ""
def goal(self, paragraph_id: str) -> str:
brief = _brief(self.diagnosis, paragraph_id)
return str((brief or {}).get("rewrite_goal") or "").strip()
def paragraph_constraints_for(self, paragraph_id: str) -> list[str]:
return list(self.paragraph_constraints.get(paragraph_id) or [])
def anchors_for_diagnosis(self) -> list[str]:
"""诊断用的语义锚点:顾问确认值优先,没给就退回理解阶段的产出。
退回这条不是兜底而是**保持既有行为**——前端原 requestDiagnosis() 就是
`analysis.paragraphs.map(p => p.semantic_anchor || p.natural_meaning_zh)`
对话化改造不能让它变弱。"""
if self.confirmed_anchors:
return list(self.confirmed_anchors)
out: list[str] = []
for item in (self.analysis or {}).get("paragraphs") or []:
if isinstance(item, dict):
out.append(str(item.get("semantic_anchor") or item.get("natural_meaning_zh") or ""))
return out
@dataclass
class ToolResult:
ok: bool
# 给 LLM 的紧凑摘要(进对话历史)
summary: str
# 给前端的原始结构(渲染诊断卡/理解卡;不进对话历史)
payload: dict[str, Any] | None = None
# 交互型工具:暂停本轮,等顾问选择后由下一条消息继续
stop: bool = False
clarify: dict[str, Any] | None = None
error: str = ""
def _pid_index(paragraph_id: str) -> int | None:
"""p1 → 0。容忍模型给 'p1'/'P1'/'1',给不出就返回 None(不猜)。"""
token = str(paragraph_id or "").strip().lower().lstrip("p")
return int(token) - 1 if token.isdigit() else None
def _brief(diagnosis: dict[str, Any] | None, paragraph_id: str) -> dict[str, Any] | None:
for brief in (diagnosis or {}).get("paragraph_briefs") or []:
if isinstance(brief, dict) and brief.get("paragraph_id") == paragraph_id:
return brief
return None
def _call(handler: Any, request: Any) -> dict[str, Any]:
"""转调 endpoint 处理器,把 HTTP 语义的错误翻成工具错误。
工具失败必须让 LLM 看见(SKILL.md 诚实性硬约束第 4 条:不许用自己
的想法把结果补齐),所以这里返回 error 而不是抛异常中断整轮对话。"""
try:
resp = handler(request)
except HTTPException as exc:
return {"error": str(exc.detail)}
except Exception as exc: # 未预期:同样转成可读错误,不让它打断会话
return {"error": f"{exc.__class__.__name__}{exc}"}
return {"data": resp.model_dump() if hasattr(resp, "model_dump") else resp}
# --------------------------------------------------------------------------
# 工具实现
def _analyze(ctx: ToolContext, args: dict[str, Any]) -> ToolResult:
from main import analyze # 延迟 import:见模块 docstring
if not ctx.paragraphs:
return ToolResult(ok=False, error="还没有拿到文书原文,无法分析", summary="")
out = _call(
analyze,
AnalyzeRequest(
prompt=ctx.prompt,
word_limit=ctx.word_limit,
paragraphs=ctx.paragraphs,
constraints=ctx.constraints,
),
)
if "error" in out:
return ToolResult(ok=False, error=out["error"], summary="")
data = out["data"]
ctx.analysis = data
paras = data.get("paragraphs") or []
summary = (
f"理解完成:{len(paras)} 段,摘要「{str(data.get('essay_summary') or '')[:80]}」;"
f"各段语义锚点:" + "".join(
f"p{i + 1}={str((p or {}).get('semantic_anchor') or '')[:40]}"
for i, p in enumerate(paras)
)
)
return ToolResult(ok=True, summary=summary, payload={"kind": "analysis", "data": data})
def _diagnose(ctx: ToolContext, args: dict[str, Any]) -> ToolResult:
from main import diagnose
if not ctx.paragraphs:
return ToolResult(ok=False, error="还没有拿到文书原文,无法诊断", summary="")
initial = None
if ctx.analysis:
try:
initial = AnalyzeResponse.model_validate(ctx.analysis)
except Exception:
initial = None # 结构不符就当作没有理解结果,不阻断诊断
out = _call(
diagnose,
DiagnoseRequest(
prompt=ctx.prompt,
word_limit=ctx.word_limit,
paragraphs=ctx.paragraphs,
confirmed_anchors=ctx.anchors_for_diagnosis(),
global_constraints=ctx.constraints,
paragraph_constraints=ctx.paragraph_constraints,
initial_analysis=initial,
),
)
if "error" in out:
return ToolResult(ok=False, error=out["error"], summary="")
data = out["data"]
ctx.diagnosis = data
patterns = data.get("patterns") or []
summary = (
f"诊断完成:{len(patterns)} 个 Pattern。"
+ "".join(
f"{p.get('pattern_id')} {p.get('name')}"
f"(段 {'/'.join(p.get('affected_paragraphs') or []) or '未标'}):"
f"{str(p.get('transformation_rule') or '')[:40]}"
for p in patterns
)
+ f"。总述:{str(data.get('overall_diagnosis') or '')[:120]}"
)
return ToolResult(ok=True, summary=summary, payload={"kind": "diagnosis", "data": data})
def _scaffold(ctx: ToolContext, args: dict[str, Any]) -> ToolResult:
from main import scaffold
pid = str(args.get("paragraph_id") or "").strip()
text = ctx.paragraph(pid)
if not text:
return ToolResult(ok=False, error=f"找不到段落 {pid or '(未给段号)'}", summary="")
out = _call(
scaffold,
ScaffoldRequest(
paragraph_id=f"p{_pid_index(pid) + 1}",
original_text=text,
semantic_anchor=ctx.anchor(pid),
rewrite_goal=str(args.get("rewrite_goal") or ctx.goal(pid)),
global_constraints=ctx.constraints,
paragraph_constraints=ctx.paragraph_constraints_for(f"p{_pid_index(pid) + 1}"),
),
)
if "error" in out:
return ToolResult(ok=False, error=out["error"], summary="")
data = out["data"]
return ToolResult(
ok=True,
summary=f"{pid} 写作起点:{str(data.get('scaffold') or '')[:200]}",
payload={"kind": "scaffold", "data": data},
)
def _reference(ctx: ToolContext, args: dict[str, Any]) -> ToolResult:
from main import reference
pid = str(args.get("paragraph_id") or "").strip()
text = ctx.paragraph(pid)
if not text:
return ToolResult(ok=False, error=f"找不到段落 {pid or '(未给段号)'}", summary="")
out = _call(
reference,
ReferenceRequest(
paragraph_id=f"p{_pid_index(pid) + 1}",
original_text=text,
semantic_anchor=ctx.anchor(pid),
rewrite_goal=str(args.get("rewrite_goal") or ctx.goal(pid)),
global_constraints=ctx.constraints,
paragraph_constraints=ctx.paragraph_constraints_for(f"p{_pid_index(pid) + 1}"),
),
)
if "error" in out:
return ToolResult(ok=False, error=out["error"], summary="")
data = out["data"]
return ToolResult(
ok=True,
summary=f"{pid} 参考片段:{str(data.get('reference_snippet') or '')[:200]}",
payload={"kind": "reference", "data": data},
)
def _translate(ctx: ToolContext, args: dict[str, Any]) -> ToolResult:
from main import translate
pid = str(args.get("paragraph_id") or "").strip()
text = str(args.get("text") or "").strip()
if pid and not text:
# 优先原文;顾问已在工作台改写则退回改写稿(他想核对的是自己改的那版)
text = ctx.paragraph(pid)
idx = _pid_index(pid)
if not text and idx is not None and idx < len(ctx.rewrite_paragraphs):
text = ctx.rewrite_paragraphs[idx]
if not text:
return ToolResult(ok=False, error="没有可翻译的文本", summary="")
out = _call(translate, TranslateRequest(paragraph_id=pid, text=text))
if "error" in out:
return ToolResult(ok=False, error=out["error"], summary="")
data = out["data"]
return ToolResult(
ok=True,
summary=f"中文对照:{str(data.get('translation') or '')[:300]}",
payload={"kind": "translation", "data": data},
)
def _recheck(ctx: ToolContext, args: dict[str, Any]) -> ToolResult:
from main import recheck
if not ctx.rewrite_paragraphs:
return ToolResult(
ok=False,
error="还没有拿到改写稿。复检需要顾问先在工作台完成各段改写",
summary="",
)
if len(ctx.rewrite_paragraphs) != len(ctx.paragraphs):
return ToolResult(
ok=False,
error=f"原文 {len(ctx.paragraphs)} 段、改写 {len(ctx.rewrite_paragraphs)} 段,数量不一致,无法复检",
summary="",
)
initial = None
if ctx.diagnosis:
try:
initial = DiagnoseResponse.model_validate(ctx.diagnosis)
except Exception:
initial = None # 少了诊断不影响复检其余六个维度
out = _call(
recheck,
RecheckRequest(
prompt=ctx.prompt,
word_limit=ctx.word_limit,
original_paragraphs=ctx.paragraphs,
rewrite_paragraphs=ctx.rewrite_paragraphs,
confirmed_anchors=ctx.anchors_for_diagnosis(),
global_constraints=ctx.constraints,
paragraph_constraints=ctx.paragraph_constraints,
# 不带 diagnosis 则「原 Pattern 是否缓解」这一维失去对照,复检会变弱
diagnosis=initial,
),
)
if "error" in out:
return ToolResult(ok=False, error=out["error"], summary="")
data = out["data"]
checks = data.get("global_checks") or {}
targets = data.get("revision_targets") or []
failed = [k for k, v in checks.items() if v == "fail"]
summary = (
f"复检结果:{data.get('status')}"
f"(未通过维度:{'/'.join(failed) or '无'})。"
+ (
"返工目标:" + "".join(
f"{t.get('paragraph_id')} {str(t.get('single_revision_goal') or '')[:60]}"
for t in targets
)
if targets
else "无需返工"
)
)
return ToolResult(ok=True, summary=summary, payload={"kind": "recheck", "data": data})
def _record_note(ctx: ToolContext, args: dict[str, Any]) -> ToolResult:
"""把顾问口头提的要求落成结构化约束。
存在的理由:顾问说「第三段理解不对,重点不是接受不确定」时,如果只是口头
应下来,这条要求到不了诊断——prompt 里顾问约束优先级最高(PRD §10.1),
丢了它等于把顾问的纠正当耳边风。落进 ctx 后由 _diagnose/_recheck 透传,
同时回传 payload 让前端同步自己的副本(下一轮前端会重发上下文,
两边不一致就会互相覆盖)。"""
note = str(args.get("note") or "").strip()
if not note:
return ToolResult(ok=False, error="note 不能为空", summary="")
pid = str(args.get("paragraph_id") or "").strip()
if pid:
idx = _pid_index(pid)
# 段号给错时**报错而不是降级成全篇要求**:模型幻觉出一个不存在的段号,
# 静默接受会让一条本该只作用于 p3 的要求污染全文
if idx is None or not (0 <= idx < len(ctx.paragraphs)):
return ToolResult(
ok=False,
error=f"找不到段落 {pid}(全文共 {len(ctx.paragraphs)} 段),请用 p1/p2 这样的段号;全篇要求请留空",
summary="",
)
key = f"p{idx + 1}"
bucket = ctx.paragraph_constraints.setdefault(key, [])
if note not in bucket:
bucket.append(note)
return ToolResult(
ok=True,
summary=f"已记入 {key} 的要求:{note}",
payload={"kind": "constraint", "data": {"paragraph_id": key, "note": note}},
)
if note not in ctx.constraints:
ctx.constraints.append(note)
data = {"paragraph_id": "", "note": note}
summary = f"已记入全局要求:{note}"
return ToolResult(ok=True, summary=summary, payload={"kind": "constraint", "data": data})
def _confirm(ctx: ToolContext, args: dict[str, Any]) -> ToolResult:
"""交互型工具:产出选项按钮并暂停本轮(对应 prodream 的 stopAfterToolCall)。"""
question = str(args.get("question") or "").strip()
choices = [str(c).strip() for c in (args.get("choices") or []) if str(c).strip()]
if not question or not choices:
return ToolResult(ok=False, error="let_user_confirm 需要 question 与非空 choices", summary="")
return ToolResult(
ok=True,
summary=f"已向顾问提问:{question}",
payload={"kind": "confirm", "data": {"question": question, "choices": choices}},
stop=True,
clarify={"question": question, "choices": choices},
)
# --------------------------------------------------------------------------
# 注册表
@dataclass(frozen=True)
class ToolSpec:
name: str
description: str
parameters: dict[str, Any]
run: Any
TOOLS: list[ToolSpec] = [
ToolSpec(
name="analyze_essay",
description=(
"读懂全文:给出各段的自然中文理解与「必须保留的核心意思」(语义锚点),"
"并判断文章如何回应 Essay Prompt。不产出 AI 味问题、不做质量评价。"
"顾问首次要求「分析/看看这篇」时先调它。"
),
parameters={
"type": "object",
"properties": {
"focus": {"type": "string", "description": "顾问本次特别关心的点,可留空"},
},
},
run=_analyze,
),
ToolSpec(
name="diagnose_essay",
description=(
"诊断 AI 味:找出文章中实际命中的生成式写作 Pattern(如特质宣告、完美成长弧、"
"排比对称、格言体),每个给出逐字原文证据、为什么像 AI 写、为什么影响本人感、"
"以及对应的人工改写动作;同时给出每段的改写目标与批注。"
"顾问问「哪里像 AI / 有什么问题」时调它。"
),
parameters={
"type": "object",
"properties": {
"focus": {"type": "string", "description": "顾问本次特别关心的点,可留空"},
},
},
run=_diagnose,
),
ToolSpec(
name="get_writing_scaffold",
description=(
"给某一段一个轻量写作起点(填空/思考顺序/一句不完整起笔)。"
"顾问说「这段不知道怎么改/给个方向」时调它。不产出完整段落。"
),
parameters={
"type": "object",
"properties": {
"paragraph_id": {"type": "string", "description": "段落 id,如 p2"},
"rewrite_goal": {"type": "string", "description": "本轮这一段要完成的变化,可留空(留空则用诊断给出的目标)"},
},
"required": ["paragraph_id"],
},
run=_scaffold,
),
ToolSpec(
name="get_reference_snippet",
description=(
"给某一段 1–2 句局部参考,用来说明改写动作长什么样。"
"顾问卡住、说「给个例子/参考」时调它。不是整段答案。"
),
parameters={
"type": "object",
"properties": {
"paragraph_id": {"type": "string", "description": "段落 id,如 p2"},
"rewrite_goal": {"type": "string", "description": "本轮这一段要完成的变化,可留空"},
},
"required": ["paragraph_id"],
},
run=_reference,
),
ToolSpec(
name="translate_to_chinese",
description=(
"把原文某段(或顾问指定的英文文本)译成中文,用于核对改写后有没有偏离原意。"
"忠实直译,不做评价与润色。"
),
parameters={
"type": "object",
"properties": {
"paragraph_id": {"type": "string", "description": "段落 id,如 p2;与 text 二选一"},
"text": {"type": "string", "description": "要翻译的英文文本;留空则翻译 paragraph_id 对应的原文"},
},
},
run=_translate,
),
ToolSpec(
name="recheck_rewrite",
description=(
"对顾问已完成的整篇改写稿做全文复检:语义保留、AI 味缓解、有无新替代模板、"
"全篇声音一致性、是否套用支架/参考、字数。只在顾问说「改完了/提交复检」时调它。"
"注意:你无法替顾问改稿,此工具读的是顾问在工作台的改写稿。"
),
parameters={"type": "object", "properties": {}},
run=_recheck,
),
ToolSpec(
name="record_advisor_note",
description=(
"把顾问提出的一条要求/纠正记入工作记录,后续诊断与复检都会遵守。"
"顾问说「这段理解不对」「保留某个表达」「语言更口语化」这类话时必须调用它——"
"只口头答应不记录,要求会丢失。"
"针对具体段落就传 paragraph_id,全篇性的就留空。"
),
parameters={
"type": "object",
"properties": {
"note": {"type": "string", "description": "顾问的要求原文或其准确转述,一句话"},
"paragraph_id": {"type": "string", "description": "只针对某一段时填,如 p2;全篇要求留空"},
},
"required": ["note"],
},
run=_record_note,
),
ToolSpec(
name="let_user_confirm",
description=(
"向顾问提问并给出可点选的选项,然后暂停等你回答。"
"用于需要顾问拍板的场合:选哪一段、确认改写方向、下一步做什么。"
"调用后本轮结束,顾问的选择会作为下一条消息回到你这里。"
),
parameters={
"type": "object",
"properties": {
"question": {"type": "string", "description": "要问顾问的问题,一句话"},
"choices": {
"type": "array",
"items": {"type": "string"},
"description": "2–4 个选项,每个是一句可直接执行的话",
},
},
"required": ["question", "choices"],
},
run=_confirm,
),
]
_BY_NAME = {spec.name: spec for spec in TOOLS}
def openai_tools() -> list[dict[str, Any]]:
"""转成 OpenAI 兼容的 tools 声明(engine 侧剥掉的 execUrl 在这里由注册表承担)。"""
return [
{
"type": "function",
"function": {
"name": spec.name,
"description": spec.description,
"parameters": spec.parameters,
},
}
for spec in TOOLS
]
def execute(name: str, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
spec = _BY_NAME.get(name)
if spec is None:
return ToolResult(ok=False, error=f"未知工具 {name}", summary="")
try:
return spec.run(ctx, args or {})
except Exception as exc: # 兜底:单个工具崩溃不能带走整轮对话
return ToolResult(ok=False, error=f"{exc.__class__.__name__}{exc}", summary="")