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 + 真实字节回放」这一层。
This commit is contained in:
LuminousRuoxi
2026-09-11 10:25:12 +08:00
parent 493c7f6416
commit 94a26227c1
7 changed files with 2170 additions and 73 deletions
+563
View File
@@ -0,0 +1,563 @@
"""对话层的工具注册表。
设计取舍(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="")