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
+308
View File
@@ -0,0 +1,308 @@
"""最小对话底座内核。
复刻 prodream dreami engine 的三件事(形态与契约,不是那套服务):
1. **skill 注入**SKILL.md 全文拼进 system prompt(固定注入,不做相关性排序)
—— 与 engine 的 skill injection 同形,业务逻辑留在 host 这一侧。
2. **工具循环**LLM ⇄ 工具,直到没有 tool_call 为止。engine 跑的就是这个循环。
3. **SSE 事件**:产出 transport 无关的事件字典,由 main.py 序列化成 wire 格式。
明确不做(demo 不需要,做了只是负担):计费、鉴权、附件、MongoDB、
caller assertion、向量相关性排序、独立的 engine 进程。
分层原则(决定了失败模式):
- 对话层 = 流式纯文本 + tools,不强制 JSON,不重试(失败即报错,用户看得见)
- 工具层 = 非流式 JSON,走 llm.complete_json 的既有重试兜底(那套是验证过的)
"""
from __future__ import annotations
import json
import logging
import re
import uuid
from pathlib import Path
from typing import Any, Iterator
from llm import LLMError, LlmClient
from tools import ToolContext, execute, openai_tools
logger = logging.getLogger("hvr.agent")
HERE = Path(__file__).resolve().parent
SKILL_ROOT = HERE / "skills"
# 一次用户输入最多触发几轮 LLM(每轮可能带一次工具调用)。
# 4 足够走完「判断 → 调工具 → 汇报」;给到 6 是为了容忍模型多调一次工具。
MAX_TURNS = 6
# 会话历史上限(条)。工具结果进历史的是紧凑摘要,但仍要有上限兜底。
MAX_HISTORY = 40
# 新会话没有用户输入时的开场(SKILL.md 规定首轮由 agent 主动盘点)
KICKOFF = "请开始首轮盘点。"
_FRONTMATTER = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
_SKILL_CACHE: dict[str, str] = {}
_SESSIONS: dict[str, dict[str, Any]] = {}
# --------------------------------------------------------------------------
# skill 加载
def load_skill(name: str) -> str:
"""读 SKILL.md 并剥掉 frontmatter —— 拼进 system prompt 的是正文。
frontmatter 是给注册链路读的元数据(engine 用 name/description/tags 做场景
命中);这里按名直取,没有命中环节,所以元数据对模型是纯噪声。"""
if name in _SKILL_CACHE:
return _SKILL_CACHE[name]
path = SKILL_ROOT / name / "SKILL.md"
if not path.is_file():
raise LLMError(f"找不到 skill{name}{path}")
text = _FRONTMATTER.sub("", path.read_text(encoding="utf-8")).strip()
if not text:
raise LLMError(f"skill 内容为空:{name}")
# 缓存是刻意的:SKILL.md 是静态资产,会话期间不会变;改完 skill 需重启进程
_SKILL_CACHE[name] = text
return text
def _context_block(ctx: ToolContext) -> str:
"""业务上下文块。原文只在这里注入一次,工具入参不重抄 —— 这是省 token
与防篡改(模型重抄原文可能改写原文)的关键。"""
lines: list[str] = ["<essay_context>"]
lines.append(f"Essay Prompt: {ctx.prompt or '(未提供)'}")
lines.append(f"Word Limit: {ctx.word_limit or '(未提供)'}")
lines.append("段落原文:")
for i, text in enumerate(ctx.paragraphs, start=1):
lines.append(f"<p{i}>{text}</p{i}>")
if ctx.rewrite_paragraphs:
lines.append("顾问在工作台的当前改写稿:")
for i, text in enumerate(ctx.rewrite_paragraphs, start=1):
lines.append(f"<p{i}>{text}</p{i}>")
else:
lines.append("顾问在工作台的当前改写稿:(尚未开始改写)")
if ctx.constraints:
lines.append("顾问全局要求:" + "".join(ctx.constraints))
lines.append("</essay_context>")
done: list[str] = []
if ctx.analysis:
done.append("已完成理解(analyze_essay")
if ctx.diagnosis:
done.append("已完成诊断(diagnose_essay")
lines.append("已完成步骤:" + ("".join(done) if done else ""))
return "\n".join(lines)
def build_system_prompt(ctx: ToolContext, skill_name: str = "hvr-rewrite") -> str:
return f"{load_skill(skill_name)}\n\n---\n\n# 当前业务上下文\n\n{_context_block(ctx)}"
# --------------------------------------------------------------------------
# 会话
def _new_session(req: dict[str, Any]) -> dict[str, Any]:
ctx = ToolContext(
prompt=str(req.get("prompt") or ""),
word_limit=req.get("word_limit") or None,
paragraphs=[str(p) for p in (req.get("paragraphs") or [])],
constraints=[str(c) for c in (req.get("constraints") or [])],
rewrite_paragraphs=[str(p) for p in (req.get("rewrite_paragraphs") or [])],
confirmed_anchors=[str(a) for a in (req.get("confirmed_anchors") or [])],
paragraph_constraints={
str(k): [str(x) for x in (v or [])]
for k, v in (req.get("paragraph_constraints") or {}).items()
},
# 前端在会话第一轮把已完成的分析/诊断带过来(页面刷新、进程重启后新开会话时
# 服务端手里没有)。丢了 diagnosis 会让复检的「原 Pattern 是否缓解」失去对照,
# 丢了 analysis 会让语义锚点变空 —— 都是静默降级,所以这里必须收。
analysis=req.get("analysis") or None,
diagnosis=req.get("diagnosis") or None,
)
return {"sid": uuid.uuid4().hex[:16], "ctx": ctx, "messages": []}
def get_session(session_id: str | None, req: dict[str, Any]) -> dict[str, Any]:
"""续接已有会话;没给 id 或 id 不认识就开新会话。
id 不认识时开新会话而不是报错:进程重启会清空 _SESSIONS,前端手里的旧 id
不该让用户卡死。代价是丢历史,但原文由前端每轮重发,业务上下文不丢。"""
if session_id and session_id in _SESSIONS:
sess = _SESSIONS[session_id]
_refresh_ctx(sess["ctx"], req)
return sess
sess = _new_session(req)
_SESSIONS[sess["sid"]] = sess
# 会话数上限:demo 场景不会有几百个会话,超了丢最早的防内存无限增长
while len(_SESSIONS) > 200:
_SESSIONS.pop(next(iter(_SESSIONS)))
return sess
def _refresh_ctx(ctx: ToolContext, req: dict[str, Any]) -> None:
"""前端每轮重发业务上下文 —— 顾问可能刚在工作台改完稿、刚补了要求。
已完成的分析/诊断结果保留,不因重发而丢。"""
if req.get("paragraphs"):
ctx.paragraphs = [str(p) for p in req["paragraphs"]]
if "rewrite_paragraphs" in req:
ctx.rewrite_paragraphs = [str(p) for p in (req.get("rewrite_paragraphs") or [])]
if "constraints" in req:
ctx.constraints = [str(c) for c in (req.get("constraints") or [])]
if "confirmed_anchors" in req:
ctx.confirmed_anchors = [str(a) for a in (req.get("confirmed_anchors") or [])]
if "paragraph_constraints" in req:
ctx.paragraph_constraints = {
str(k): [str(x) for x in (v or [])]
for k, v in (req.get("paragraph_constraints") or {}).items()
}
if "prompt" in req:
ctx.prompt = str(req.get("prompt") or "")
if "word_limit" in req:
ctx.word_limit = req.get("word_limit") or None
def drop_session(session_id: str) -> bool:
return _SESSIONS.pop(session_id, None) is not None
def _trim(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""保留最近 MAX_HISTORY 条,但**不能从 tool 消息开头切**——OpenAI 协议要求
tool 消息必须紧跟带 tool_calls 的 assistant 消息,悬空的 tool 消息会被拒。"""
if len(messages) <= MAX_HISTORY:
return messages
cut = len(messages) - MAX_HISTORY
while cut < len(messages) and messages[cut].get("role") == "tool":
cut += 1
return messages[cut:]
# --------------------------------------------------------------------------
# 事件
def _ev(name: str, **data: Any) -> dict[str, Any]:
return {"event": name, "data": data}
# --------------------------------------------------------------------------
# 工具循环
def run_agent_stream(
sess: dict[str, Any],
user_message: str,
client: LlmClient,
skill_name: str = "hvr-rewrite",
) -> Iterator[dict[str, Any]]:
"""跑一轮对话,逐条 yield 事件字典。
事件契约(照搬 prodream,见 docs/Agent化改造方案_20260911.md §四):
meta / session_id / token / thinking / tool_call / tool_result /
tool_request_clarify / done / error
"""
ctx: ToolContext = sess["ctx"]
history: list[dict[str, Any]] = sess["messages"]
trace_id = uuid.uuid4().hex[:16]
yield _ev("meta", trace_id=trace_id)
yield _ev("session_id", session_id=sess["sid"])
if not ctx.paragraphs:
yield _ev("error", code="no_essay", msg="还没有拿到文书原文,请先在左侧粘贴并切分段落")
return
history.append({"role": "user", "content": user_message or KICKOFF})
messages = [{"role": "system", "content": build_system_prompt(ctx, skill_name)}, *_trim(history)]
try:
for turn in range(MAX_TURNS):
text_parts: list[str] = []
tool_calls: list[dict[str, str]] = []
finish = ""
for ev in client.stream_chat(messages, tools=openai_tools()):
kind = ev.get("type")
if kind == "text":
text_parts.append(ev["text"])
yield _ev("token", text=ev["text"])
elif kind == "thinking":
yield _ev("thinking", text=ev["text"])
elif kind == "tool_calls":
tool_calls = ev["tool_calls"]
elif kind == "done":
finish = ev.get("finish_reason") or "stop"
if not tool_calls:
history.append({"role": "assistant", "content": "".join(text_parts)})
yield _ev("done", finish_reason=finish or "stop")
return
# 工具调用轮:正文通常是空的,仍要落历史(部分模型会给一句过渡语)
history.append(
{
"role": "assistant",
"content": "".join(text_parts) or None,
"tool_calls": [
{
"id": c["id"],
"type": "function",
"function": {"name": c["name"], "arguments": c["arguments"] or "{}"},
}
for c in tool_calls
],
}
)
messages.append(history[-1])
for call in tool_calls:
name = call.get("name") or ""
try:
args = json.loads(call.get("arguments") or "{}")
if not isinstance(args, dict):
args = {}
except json.JSONDecodeError:
args = {}
logger.warning("tool args 不是合法 JSON%s", (call.get("arguments") or "")[:200])
yield _ev("tool_call", tool=name, tool_call_id=call["id"], args=args)
result = execute(name, args, ctx)
if not result.ok:
logger.warning("tool failed name=%s error=%s", name, result.error)
yield _ev(
"tool_result",
tool=name,
tool_call_id=call["id"],
ok=result.ok,
result_summary=result.summary or result.error,
payload=result.payload,
)
# 进历史的是摘要(或错误原文)——诊断 JSON 有 2.5k tokens
# 原样回灌会让每轮成本滚雪球
tool_msg = {
"role": "tool",
"tool_call_id": call["id"],
"content": json.dumps(
{"ok": result.ok, "result": result.summary or result.error},
ensure_ascii=False,
),
}
history.append(tool_msg)
messages.append(tool_msg)
if result.stop:
# 交互型工具:本轮到此为止,等顾问点选后由下一条消息继续
logger.warning("agent await user name=%s turn=%s", name, turn)
yield _ev(
"tool_request_clarify",
clarify_id=call["id"],
question=(result.clarify or {}).get("question", ""),
choices=(result.clarify or {}).get("choices", []),
)
yield _ev("done", finish_reason="await_user")
return
# 轮次用尽:不是错误,但要如实告诉前端为什么停
logger.warning("agent hit MAX_TURNS=%s", MAX_TURNS)
yield _ev("error", code="max_turns", msg=f"本次操作步骤过多(超过 {MAX_TURNS} 轮),请把要求拆开再问")
except LLMError as exc:
logger.warning("agent llm error: %s", exc)
yield _ev("error", code="llm_error", msg=str(exc))
finally:
sess["messages"] = _trim(history)
+260 -71
View File
@@ -46,6 +46,20 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
.bubble h1{font-size:25px;margin:0 0 7px;line-height:1.35}.bubble h2{font-size:18px;margin:20px 0 10px}.bubble h3{font-size:14px;margin:15px 0 7px}.bubble p{margin:7px 0;color:#414757}.user .bubble p{color:#fff;margin:0}.eyebrow{color:var(--brand);font-size:12px;font-weight:800;margin-bottom:7px}.bubble .muted{color:var(--muted);font-size:13px}.summary{background:#f8f8ff;border:1px solid #dfe1ff;border-radius:12px;padding:14px 16px;color:#30364a;line-height:1.75;margin:12px 0} .bubble h1{font-size:25px;margin:0 0 7px;line-height:1.35}.bubble h2{font-size:18px;margin:20px 0 10px}.bubble h3{font-size:14px;margin:15px 0 7px}.bubble p{margin:7px 0;color:#414757}.user .bubble p{color:#fff;margin:0}.eyebrow{color:var(--brand);font-size:12px;font-weight:800;margin-bottom:7px}.bubble .muted{color:var(--muted);font-size:13px}.summary{background:#f8f8ff;border:1px solid #dfe1ff;border-radius:12px;padding:14px 16px;color:#30364a;line-height:1.75;margin:12px 0}
.agent-table{width:100%;border-collapse:separate;border-spacing:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;margin:10px 0 14px;font-size:13px}.agent-table th{background:#f8f9fc;text-align:left;padding:10px 11px;color:#687084;font-weight:700;border-bottom:1px solid var(--line)}.agent-table td{padding:11px;border-bottom:1px solid var(--line);vertical-align:top}.agent-table tr:last-child td{border-bottom:0}.agent-table td:first-child{font-weight:700;white-space:nowrap} .agent-table{width:100%;border-collapse:separate;border-spacing:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;margin:10px 0 14px;font-size:13px}.agent-table th{background:#f8f9fc;text-align:left;padding:10px 11px;color:#687084;font-weight:700;border-bottom:1px solid var(--line)}.agent-table td{padding:11px;border-bottom:1px solid var(--line);vertical-align:top}.agent-table tr:last-child td{border-bottom:0}.agent-table td:first-child{font-weight:700;white-space:nowrap}
.inline-note{background:#fafbff;border-left:3px solid #7775f4;padding:9px 12px;border-radius:5px;margin:10px 0;color:#555c70;font-size:13px}.quick-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.quick{border:1px solid #dfe1ec;background:#fff;border-radius:999px;padding:8px 12px;font-size:12px;color:#4f5668;font-weight:650}.quick:hover{border-color:#b8b9ff;color:#4440d5;background:#f8f8ff}.quick.primary{background:var(--brand);border-color:var(--brand);color:#fff;padding:10px 18px;box-shadow:0 6px 16px rgba(86,84,245,.18)}.quick.primary:hover{background:var(--brand2);color:#fff}.quick.ghost{background:#fff}.quick:disabled{opacity:.45;cursor:not-allowed} .inline-note{background:#fafbff;border-left:3px solid #7775f4;padding:9px 12px;border-radius:5px;margin:10px 0;color:#555c70;font-size:13px}.quick-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.quick{border:1px solid #dfe1ec;background:#fff;border-radius:999px;padding:8px 12px;font-size:12px;color:#4f5668;font-weight:650}.quick:hover{border-color:#b8b9ff;color:#4440d5;background:#f8f8ff}.quick.primary{background:var(--brand);border-color:var(--brand);color:#fff;padding:10px 18px;box-shadow:0 6px 16px rgba(86,84,245,.18)}.quick.primary:hover{background:var(--brand2);color:#fff}.quick.ghost{background:#fff}.quick:disabled{opacity:.45;cursor:not-allowed}
/* Agent 对话(SSE 流式):正文逐字出 + 工具卡片 + 选项按钮 */
.stream-body p{margin:0 0 10px}.stream-body p:last-child{margin-bottom:0}
.stream-body ul.agent-list{margin:0 0 10px;padding-left:18px}.stream-body ul.agent-list li{margin-bottom:4px}
.stream-error{color:#c0392b;font-size:13px;margin:8px 0 0}
.tool-cards{display:flex;flex-direction:column;gap:6px;margin-top:10px}
.tool-card{display:flex;align-items:center;gap:8px;background:#f6f7fc;border:1px solid #e6e8f4;border-radius:8px;padding:7px 11px;font-size:12px;color:#555c70}
.tool-card .tool-name{font-weight:650;color:#4f5668;white-space:nowrap;flex:none}
.tool-card .tool-sum{color:#7a8095;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tool-card.running{border-style:dashed}
.tool-card.failed{background:#fff6f6;border-color:#f2d5d5}
.tool-card.failed .tool-name{color:#a94442}.tool-card.failed .tool-sum{color:#c0392b;white-space:normal}
.tool-dot{width:7px;height:7px;border-radius:50%;background:#3bb273;flex:none}
.tool-card.failed .tool-dot{background:#e05c5c}
.choice-q{margin:0;font-size:13px;color:#4f5668;font-weight:650;flex:0 0 100%}
.agent-input{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(180deg,rgba(246,247,251,0),#f6f7fb 26%);padding:30px 20px 18px;z-index:40}.composer{max-width:900px;margin:0 auto;background:#fff;border:1px solid #dcdfe8;border-radius:15px;padding:9px 10px 9px 14px;display:flex;gap:10px;align-items:flex-end;box-shadow:0 10px 28px rgba(28,31,45,.09)}.composer textarea{flex:1;border:0;outline:0;resize:none;min-height:38px;max-height:120px;padding:7px 4px;font-size:13px}.composer button{width:36px;height:36px;border-radius:10px;border:0;background:var(--brand);color:#fff;font-weight:800}.input-hint{max-width:900px;margin:8px auto 0;color:#d83a3a;font-size:12px;font-weight:600;padding-left:14px} .agent-input{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(180deg,rgba(246,247,251,0),#f6f7fb 26%);padding:30px 20px 18px;z-index:40}.composer{max-width:900px;margin:0 auto;background:#fff;border:1px solid #dcdfe8;border-radius:15px;padding:9px 10px 9px 14px;display:flex;gap:10px;align-items:flex-end;box-shadow:0 10px 28px rgba(28,31,45,.09)}.composer textarea{flex:1;border:0;outline:0;resize:none;min-height:38px;max-height:120px;padding:7px 4px;font-size:13px}.composer button{width:36px;height:36px;border-radius:10px;border:0;background:var(--brand);color:#fff;font-weight:800}.input-hint{max-width:900px;margin:8px auto 0;color:#d83a3a;font-size:12px;font-weight:600;padding-left:14px}
.status-line{display:flex;gap:8px;align-items:center;margin:7px 0 13px;color:#737a8b;font-size:12px}.status-line .pill{padding:4px 8px;border-radius:999px;background:#f1f2f6}.constraint-box{margin-top:13px;background:#f9faff;border:1px solid #e0e2ef;border-radius:11px;padding:11px 13px;font-size:13px;color:#52596c} .status-line{display:flex;gap:8px;align-items:center;margin:7px 0 13px;color:#737a8b;font-size:12px}.status-line .pill{padding:4px 8px;border-radius:999px;background:#f1f2f6}.constraint-box{margin-top:13px;background:#f9faff;border:1px solid #e0e2ef;border-radius:11px;padding:11px 13px;font-size:13px;color:#52596c}
@@ -505,7 +519,7 @@ function persistNow(){
referencesShown, scaffoldsShown, currentRewriteVersion, referencesShown, scaffoldsShown, currentRewriteVersion,
lastRecheckSnapshot, lastRecheckResult, analysisVersion, agentStage, lastRecheckSnapshot, lastRecheckResult, analysisVersion, agentStage,
chatLog, essayVersions, viewedVersion, recheckHistory, translations, translateOpen, chatLog, essayVersions, viewedVersion, recheckHistory, translations, translateOpen,
origTranslations, origTranslateOpen origTranslations, origTranslateOpen, chatSessionId
})); }));
return true; return true;
}catch(e){ return false; } }catch(e){ return false; }
@@ -559,6 +573,9 @@ function restoreSession(){
lastRecheckResult = s.lastRecheckResult || null; lastRecheckResult = s.lastRecheckResult || null;
analysisVersion = s.analysisVersion || 1; analysisVersion = s.analysisVersion || 1;
chatLog = Array.isArray(s.chatLog) ? s.chatLog : []; chatLog = Array.isArray(s.chatLog) ? s.chatLog : [];
// 续接服务端会话:不恢复的话刷新后同一条历史里发出的消息会落到新开的空会话,
// agent 会忘记刚才聊过什么(服务端进程重启同样会落到这条兜底路径,行为一致)
chatSessionId = typeof s.chatSessionId === 'string' ? s.chatSessionId : null;
chatLogSeq = chatLog.length ? Math.max(...chatLog.map(r=>r.id||0)) : 0; chatLogSeq = chatLog.length ? Math.max(...chatLog.map(r=>r.id||0)) : 0;
essayVersions = Array.isArray(s.essayVersions) ? s.essayVersions : []; essayVersions = Array.isArray(s.essayVersions) ? s.essayVersions : [];
viewedVersion = essayVersions.some(v=>v.version===s.viewedVersion) ? s.viewedVersion : null; viewedVersion = essayVersions.some(v=>v.version===s.viewedVersion) ? s.viewedVersion : null;
@@ -646,29 +663,12 @@ async function runAnalyze(){
viewedVersion = null; // 新一轮以原版为工作底稿(已完成的版本 N 仍保留可切换) viewedVersion = null; // 新一轮以原版为工作底稿(已完成的版本 N 仍保留可切换)
ensureParaState(); ensureParaState();
track('human_voice_analysis_start', {paragraphs: paragraphs.length}); track('human_voice_analysis_start', {paragraphs: paragraphs.length});
show('agentView'); chat.innerHTML=''; chatLog=[]; agentStage='loading'; show('agentView'); chat.innerHTML=''; chatLog=[]; chatSessionId=null;
// 统一走 addLoadingCard:带 45s/120s 超时提示(真实模型生成可能超过 1 分钟) agentStage='loading';
const loading = addLoadingCard('正在读懂这篇文书…', ['正在结合题目理解全文','正在确认每一段实际表达的内容','正在整理需要与你确认的理解']); // 首轮不再直接打 /api/analyze:交给 agent 按 SKILL.md「首轮盘点」自己调 analyze_essay
const rows = loading.querySelectorAll('.loading-row'); // 盘点结论 + 理解卡一起给出,并由它用 let_user_confirm 问顾问下一步。
const t1 = setTimeout(()=>{rows[0].classList.remove('active');rows[1].classList.add('active')},650); // 改造前这里是一条固定流水线(分析 → 等确认 → 诊断),现在由 skill 驱动。
const t2 = setTimeout(()=>{rows[1].classList.remove('active');rows[2].classList.add('active')},1300); streamAgent('请开始首轮盘点。');
try{
const [resp] = await Promise.all([
postJSON('/api/analyze',{prompt:promptText, word_limit:wordLimit, paragraphs, constraints:[]}),
sleep(900)
]);
clearTimeout(t1);clearTimeout(t2);
removeEl(loading);
analysis = resp;
agentStage='understanding';
persistNow();
renderUnderstanding();
}catch(e){
clearTimeout(t1);clearTimeout(t2);
removeEl(loading);
agentStage='idle';
renderError(e.message, runAnalyze);
}
} }
async function requestDiagnosis(){ async function requestDiagnosis(){
@@ -681,29 +681,10 @@ async function requestDiagnosis(){
}); });
return resp; return resp;
} }
async function runDiagnose(opts){ // 原先这里有一条对话页专用的 runDiagnose(带 loading 卡、跑 /api/diagnose 再渲染诊断卡)。
// 防重入:诊断请求进行中忽略重复触发(用户实测连点【理解准确,继续分析】—— // Agent 化之后诊断由 agent 调 diagnose_essay 工具触发,载荷经 applyToolPayload 走同一条
// 修复前两次并发请求会各渲染一张诊断卡并白花一次模型调用,见 Deviations #23 // renderDiagnosis,这个函数已无调用点,删掉避免两套并行的诊断路径各自漂移。
if(agentStage==='diagloading') return; // 工作台的 submitConstraint 仍直接调 requestDiagnosis —— 那是工作台自己的路径,未改动。
const regen = !!(opts && opts.regen);
agentStage = 'diagloading';
const loading = regen
? addLoadingCard('正在根据你的补充重新诊断…', ['正在合并你新确认的要求','正在重新生成各段批注与改写方向'])
: addLoadingCard('正在分析明显的 AI 写作习惯…', ['正在对照已确认的语义锚点','正在识别实际存在的生成式写作模式','正在整理各段改写方向']);
try{
const resp = await requestDiagnosis();
removeEl(loading);
diagnosis = resp;
agentStage='diagnosis';
persistNow();
renderDiagnosis();
}catch(e){
removeEl(loading);
// 重诊断失败时约束已记入,保留 diagnosis 状态可再次重试
agentStage = regen ? 'diagnosis' : 'understanding';
renderError(e.message, ()=>runDiagnose(opts));
}
}
function renderDiagnosis(){ function renderDiagnosis(){
const d = diagnosis; const d = diagnosis;
@@ -754,10 +735,14 @@ function handleAction(action){
// 对话页与 Workbench 都能显示。 // 对话页与 Workbench 都能显示。
if(action==='view-recheck-history'){ renderRecheckHistory(); toggleRecheckHistory(); return; } if(action==='view-recheck-history'){ renderRecheckHistory(); toggleRecheckHistory(); return; }
if(action==='confirm-understanding'){ if(action==='confirm-understanding'){
// 防重入:诊断请求进行中忽略连点(用户实测连点——修复前两次并发请求 + // 防重入:这一轮 agent 还没跑完就忽略连点(原先防的是两次并发 /api/diagnose
// 两个确认气泡 + 两张诊断卡,见 Deviations #23);runDiagnose 内另有同守卫兜底 // 连点会各渲染一张诊断卡;现在连点会各起一条 SSE 流,等价问题)
if(agentStage==='diagloading') return; if(agentBusy||agentStage==='diagloading') return;
addUser('理解准确,继续。');track('understanding_confirmed');runDiagnose(); track('understanding_confirmed');
addUser('理解准确,继续。');
// 不再直接调 runDiagnose():由 agent 自己决定下一步并调 diagnose_essay
// 它要能在这条消息里同时处理「确认 + 顺带的补充要求」
streamAgent('顾问确认理解准确,请继续。');
} }
if(action==='adjust-understanding'){const input=$('#chatInput');input.placeholder='例如:第三段重点不是接受不确定,而是更关注推理过程……';input.focus();} if(action==='adjust-understanding'){const input=$('#chatInput');input.placeholder='例如:第三段重点不是接受不确定,而是更关注推理过程……';input.focus();}
// 每个按钮给出自己的占位提示再聚焦(只 focus 会把其它按钮的提示残留下来——用户实测): // 每个按钮给出自己的占位提示再聚焦(只 focus 会把其它按钮的提示残留下来——用户实测):
@@ -826,30 +811,232 @@ function showInputHint(){
clearTimeout(hint._t); clearTimeout(hint._t);
hint._t=setTimeout(()=>hint.classList.add('hidden'),3000); hint._t=setTimeout(()=>hint.classList.add('hidden'),3000);
} }
/* ===== Agent 对话(SSE 流式)=====
原先是按 agentStage 分支回复的固定状态机(理解态收补充→要求再点确认、诊断态收补充→
直接重跑…)。现在用户说什么都原样发给 /api/chat/stream,由 skill 驱动的 agent 自己
判断该聊还是该调工具。
agentStage 保留下来了,但语义变了:它不再是「回复分支的依据」,而是「哪些工具跑过」
的结果——工作台入口与按钮置灰仍按它判断(renderDiagnosis 的【进入逐段改写】、
disableCompletedActions 的置灰规则都依赖它)。*/
let chatSessionId = null;
let agentBusy = false;
const TOOL_LABEL = {
analyze_essay:'读懂全文', diagnose_essay:'诊断 AI 味',
get_writing_scaffold:'给写作起点', get_reference_snippet:'给参考片段',
translate_to_chinese:'中文对照', recheck_rewrite:'全文复检',
record_advisor_note:'记下你的要求', let_user_confirm:'等你确认',
};
// 这三个工具不是流式出字的(走 complete_json 保住校验重试),调用期间只有工具卡片在转,
// 长文跑 1 分钟以上很正常——不写清楚顾问会以为卡死了
const SLOW_TOOLS = {analyze_essay:'正在读懂全文,长文可能要 1 分钟以上…',
diagnose_essay:'正在逐段诊断,可能要 1 分钟以上…',
recheck_rewrite:'正在全文复检,可能要 1 分钟以上…'};
function toolLabel(name){ return TOOL_LABEL[name] || name; }
// 只认模型真正常用的那三种(粗体/行内代码/标题)。输入已经过 escapeHtml
// 这里插进去的标签全是我们自己拼的,模型的 <> 早变成实体了,不会二次注入。
function mdInline(s){
return s.replace(/\*\*([^*\n]+)\*\*/g,'<strong>$1</strong>').replace(/`([^`\n]+)`/g,'<code>$1</code>');
}
function renderAgentText(escaped){
// 入参已 escapeHtml——流式渲染拼 DOM 时必须先转义再拼,模型输出等同用户输入
return String(escaped).split(/\n{2,}/).map(block=>{
// 逐行分组而不是「整块要么是列表要么是段落」:模型常写成
// 「**2. 最像 AI 写的地方**」这种伪标题 + 紧跟几行短横线,整块判定会把 '-' 漏成字面字符
const out = []; let para = []; let items = [];
const flushPara = ()=>{ if(para.length){ out.push(`<p>${mdInline(para.join('<br>'))}</p>`); para=[]; } };
const flushList = ()=>{ if(items.length){ out.push('<ul class="agent-list">'+items.map(x=>`<li>${mdInline(x)}</li>`).join('')+'</ul>'); items=[]; } };
block.split('\n').map(l=>l.trim()).filter(Boolean).forEach(line=>{
const item = line.match(/^[-•*]\s+(.*)$/);
const head = line.match(/^#{1,6}\s+(.*)$/);
if(item){ flushPara(); items.push(item[1]); return; }
if(head){ flushList(); flushPara(); out.push(`<h2>${mdInline(head[1])}</h2>`); return; }
flushList(); para.push(line);
});
flushPara(); flushList();
return out.join('');
}).join('');
}
// POST 用不了 EventSource,手工解 SSE 帧:`event: X\ndata: {...}\n\n`
async function readSSE(resp, onEvent){
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = '';
for(;;){
const {done, value} = await reader.read();
if(done) break;
buf += dec.decode(value, {stream:true}).replace(/\r\n/g,'\n');
let i;
while((i = buf.indexOf('\n\n')) >= 0){
const frame = buf.slice(0,i); buf = buf.slice(i+2);
let name = 'message', data = '';
frame.split('\n').forEach(line=>{
if(line.startsWith('event:')) name = line.slice(6).trim();
else if(line.startsWith('data:')) data += line.slice(5).trim();
});
if(data){ try{ onEvent(name, JSON.parse(data)); }catch(e){} }
}
}
}
function agentPayload(message){
// 业务上下文每轮重发:顾问可能刚在工作台改完稿。服务端 session 只留分析/诊断结果。
const base = {
session_id: chatSessionId, message,
prompt: promptText, word_limit: wordLimit, paragraphs: paragraphs.slice(),
constraints: globalConstraints.slice(),
rewrite_paragraphs: paragraphs.map((_,i)=>rewrites[i]||''),
confirmed_anchors: ((analysis&&analysis.paragraphs)||[]).map(p=>p.semantic_anchor||p.natural_meaning_zh||''),
paragraph_constraints: paragraphConstraints,
};
// 新会话第一轮才补上分析/诊断:刷新或服务端重启后 ctx 是空的,不带的话
// 复检会丢掉语义锚点与 Pattern 对照;拿到 session_id 之后服务端自己留着。
if(!chatSessionId){ base.analysis = analysis; base.diagnosis = diagnosis; }
return base;
}
function applyToolPayload(payload, pending){
// 工具返回的是与旧 endpoint 完全相同的结构,所以这里直接喂给已有的渲染函数——
// 复检卡、工作台入口、置灰规则、版本推进全部复用,没有第二套 UI。
const data = payload.data || {};
const pid = data.paragraph_id || '';
if(payload.kind==='analysis'){
analysis = data; analysisVersion += 1; agentStage = 'understanding';
pending.push(()=>renderUnderstanding());
}else if(payload.kind==='diagnosis'){
diagnosis = data; agentStage = 'diagnosis';
pending.push(()=>renderDiagnosis());
}else if(payload.kind==='recheck'){
// 与工作台 runRecheck 同一套收尾:先落结果与历史,再由 renderRecheck 决定
// 通过(推进版本)还是返工(设 revisionTarget)。
lastRecheckResult = data;
if(!data.checked_rewrite_version) data.checked_rewrite_version = `rv_${currentRewriteVersion}`;
pushRecheckHistory(data);
agentStage = 'recheck';
pending.push(()=>renderRecheck(data));
}else if(payload.kind==='constraint'){
// 后端把顾问要求记进 ctx 了,前端本地副本同步一份——下一轮会重发上下文,
// 两边不同步就会互相覆盖
if(pid){
const bucket = (paragraphConstraints[pid] = paragraphConstraints[pid]||[]);
if(!bucket.includes(data.note)) bucket.push(data.note);
}else if(!globalConstraints.includes(data.note)) globalConstraints.push(data.note);
}else if(payload.kind==='scaffold'){
// 走 recordExposure 而不是自己 push:复检的「有没有套用已展开的参考」
// 读的是这条记录(含段号+版本),格式不一致等于没记
if(data.scaffold){
const key = pid || pidOf(current);
helpState[key] = helpState[key] || {};
helpState[key].scaffold = true;
helpState[key].scaffoldText = data.scaffold;
recordExposure('scaffold', data.scaffold, key);
}
}else if(payload.kind==='reference'){
const text = data.reference_snippet || data.starter || '';
if(text){
const key = pid || pidOf(current);
helpState[key] = helpState[key] || {};
helpState[key].reference = true;
helpState[key].referenceText = text;
recordExposure('reference', text, key);
}
}else if(payload.kind==='translation'){
const t = data.translation || '';
// 只渲染到对话里,不写 translations[] 缓存:那份缓存按「改写稿文本相同」判新鲜,
// 而 agent 翻的可能是原文,混进去会让工作台把原文译文当成改写稿对照显示。
if(t) pending.push(()=>addAgent(`<div class="eyebrow">中文对照</div><p class="muted">${escapeHtml(pid||'')}</p><div class="summary">${escapeHtml(t)}</div>`));
}
}
function renderChoices(container, data){
const wrap = document.createElement('div');
wrap.className = 'quick-actions';
wrap.innerHTML = `<p class="choice-q">${escapeHtml(data.question||'')}</p>` +
(data.choices||[]).map((c,i)=>`<button class="quick${i===0?' primary':''}" data-choice="${escapeHtml(c)}">${escapeHtml(c)}</button>`).join('');
container.appendChild(wrap);
wrap.querySelectorAll('[data-choice]').forEach(b=>b.onclick=()=>{
wrap.querySelectorAll('button').forEach(x=>{x.disabled=true;});
addUser(b.dataset.choice);
streamAgent(b.dataset.choice);
});
scrollChat();
}
function streamAgent(message){
if(agentBusy) return;
agentBusy = true;
const m = document.createElement('div');
m.className = 'message';
m.innerHTML = '<div class="avatar">AI</div><div class="bubble"><div class="stream-body"></div><div class="tool-cards"></div></div>';
chat.appendChild(m); scrollChat();
const body = m.querySelector('.stream-body');
const cards = m.querySelector('.tool-cards');
const toolEls = {}; const pending = [];
let text = '', failed = '';
const commit = () => {
agentBusy = false;
if(failed) body.insertAdjacentHTML('beforeend', `<p class="stream-error">${escapeHtml(failed)}</p>`);
if(!text && !failed && !cards.children.length && !pending.length){ removeEl(m); return; }
// agentStage 不能停在 loading*:那三个值会锁住输入框,而这一轮已经结束了。
// 工具跑过的话 applyToolPayload 已经把它推到 understanding/diagnosis/recheck。
if(agentStage==='loading'||agentStage==='diagloading'||agentStage==='recheckloading'){
agentStage = analysis ? (diagnosis ? 'diagnosis' : 'understanding') : 'idle';
}
// 流结束才进历史快照:中途刷新 = 这轮作废(半截话留着反而误导)
m._logId = ++chatLogSeq;
chatLog.push({id:m._logId, t:'msg', html:m.querySelector('.bubble').innerHTML});
pending.forEach(fn=>{ try{ fn(); }catch(e){ track('tool_payload_render_failed',{msg:e.message}); } });
bindQuickActions(); disableCompletedActions(); persistNow(); scrollChat();
};
(async () => {
try{
const resp = await fetch('/api/chat/stream', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(agentPayload(message||'')),
});
if(!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
await readSSE(resp, (name, data)=>{
if(name==='session_id') chatSessionId = data.session_id;
else if(name==='token'){
text += data.text; body.innerHTML = renderAgentText(escapeHtml(text)); scrollChat();
}
// thinking 不渲染:顾问要的是结论,不是模型的草稿纸
else if(name==='tool_call'){
const el = document.createElement('div');
el.className = 'tool-card running';
el.innerHTML = `<span class="spinner"></span><span class="tool-name">${escapeHtml(toolLabel(data.tool))}</span><span class="tool-sum">${escapeHtml(SLOW_TOOLS[data.tool]||'')}</span>`;
cards.appendChild(el); toolEls[data.tool_call_id] = el; scrollChat();
// 复检跑起来的时候就打快照(与工作台 runRecheck 一致):复检要几十秒,
// 这段时间顾问可能切到工作台改稿。onRewriteInput 拿 lastRecheckSnapshot.version
// 比对来决定要不要给 currentRewriteVersion +1——快照晚打就会把改过的稿当成已检稿。
if(data.tool==='recheck_rewrite'){
lastRecheckSnapshot = {version:`rv_${currentRewriteVersion}`, paragraphs: rewrites.map(r=>r||'')};
dirtyDuringRecheck = false;
}
}
else if(name==='tool_result'){
const el = toolEls[data.tool_call_id];
if(el){
el.className = 'tool-card' + (data.ok?'':' failed');
el.innerHTML = `<span class="tool-dot"></span><span class="tool-name">${escapeHtml(toolLabel(data.tool))}</span><span class="tool-sum">${escapeHtml(data.result_summary||'')}</span>`;
}
if(data.ok && data.payload) applyToolPayload(data.payload, pending);
scrollChat();
}
else if(name==='tool_request_clarify') renderChoices(cards, data);
else if(name==='error') failed = data.msg || '出了点问题,请重试';
});
}catch(e){ failed = e.message || '网络异常'; }
commit();
})();
}
function processChat(text){ function processChat(text){
if(!text.trim())return; if(!text.trim())return;
if(agentStage==='loading'||agentStage==='diagloading'||agentStage==='recheckloading'){ if(agentBusy||agentStage==='loading'||agentStage==='diagloading'||agentStage==='recheckloading'){
showInputHint(); // 分析中:输入框内显示红色提示,已输入内容保留不清除 showInputHint(); // 分析中:输入框内显示红色提示,已输入内容保留不清除
return; return;
} }
addUser(text.trim());$('#chatInput').value=''; addUser(text.trim());$('#chatInput').value='';
if(agentStage==='understanding'){ streamAgent(text.trim());
const applied = applyUnderstandingCorrection(text.trim());
track('understanding_corrected', applied);
persistNow();
addAgent(`<p>明白,我把这点记作<strong>${escapeHtml(applied.label)}</strong>。后面的 Diagnosis / Rewrite Guidance / Recheck 都会以你确认后的版本为准。</p><div class="summary">${escapeHtml(text.trim())}</div><p>如果各段整体理解没有其它问题,可以继续做人味分析。</p><div class="quick-actions"><button class="quick primary" data-action="confirm-understanding">确认,继续分析 →</button></div>`);
bindQuickActions();
}else if(agentStage==='diagnosis'){
const normalized = applyDiagnosisConstraint(text.trim());
persistNow();
track('diagnosis_re_generated', {constraint: normalized});
addAgent(`<p>收到,已记入:</p><div class="constraint-box">${escapeHtml(normalized)}</div><p>正在按新要求重新生成诊断,批注与各段提示将同步更新…</p>`);
runDiagnose({regen:true});
}else if(agentStage==='recheck'){
addAgent(`<p>复检结论已经给出。你可以返回对应段落继续修改后再次提交全文复检。</p>`);
}else{
addAgent(`<p>请先点击「开始分析」,或返回文书页。</p>`);
}
} }
function fusedAiFocus(i){ function fusedAiFocus(i){
@@ -1369,9 +1556,11 @@ function onRewriteBlur(){
updateParagraphSwitcherState(); // 不重建按钮:避免点击段号时 mousedown→blur 重建导致第一次点击丢失 updateParagraphSwitcherState(); // 不重建按钮:避免点击段号时 mousedown→blur 重建导致第一次点击丢失
updateProgress(); updateProgress();
} }
function recordExposure(kind, text){ function recordExposure(kind, text, pid){
if(!text) return; if(!text) return;
const rec = {paragraph_id: pidOf(current), text, shown_at: Date.now(), analysis_version: analysisVersion, rewrite_version: currentRewriteVersion}; // pid 省略 = 工作台当前段(原调用点行为)。agent 在对话里要的支架/参考可能不是当前段,
// 记错段号会让复检的「有没有套用已展开的参考」按错误的段去比对。
const rec = {paragraph_id: pid || pidOf(current), text, shown_at: Date.now(), analysis_version: analysisVersion, rewrite_version: currentRewriteVersion};
if(kind==='scaffold'){ if(kind==='scaffold'){
if(!scaffoldsShown.some(x=>x.paragraph_id===rec.paragraph_id && x.text===text)) scaffoldsShown.push(rec); if(!scaffoldsShown.some(x=>x.paragraph_id===rec.paragraph_id && x.text===text)) scaffoldsShown.push(rec);
}else if(!referencesShown.some(x=>x.paragraph_id===rec.paragraph_id && x.text===text)){ }else if(!referencesShown.some(x=>x.paragraph_id===rec.paragraph_id && x.text===text)){
+153
View File
@@ -163,6 +163,8 @@ class LlmClient:
if not self.api_key: if not self.api_key:
raise LLMError("缺少 OpenRouter Key:请通过 local_start.sh 启动(从 prodream_backend/.env 读取)") raise LLMError("缺少 OpenRouter Key:请通过 local_start.sh 启动(从 prodream_backend/.env 读取)")
self._transport = transport self._transport = transport
# 端点是否强制开思考(gemini-3.7-flash 是)——首次 400 后锁定,见 stream_chat
self._reasoning_locked = False
# -- internal ---------------------------------------------------------- # -- internal ----------------------------------------------------------
def _post(self, payload: dict[str, Any]) -> httpx.Response: def _post(self, payload: dict[str, Any]) -> httpx.Response:
@@ -237,7 +239,158 @@ class LlmClient:
) )
return message_text(body) return message_text(body)
def _stream_post(self, payload: dict[str, Any]) -> Any:
"""流式 POST:返回已进入响应体的 stream context(调用方负责关闭)。
与 _post 分开的原因:httpx 的流式响应必须在 with 块内消费完,
不能像 _post 那样把 Response 交出去——连接会被提前关闭。"""
kwargs: dict[str, Any] = {"timeout": self.timeout}
if self._transport is not None:
kwargs["transport"] = self._transport
client = httpx.Client(trust_env=False, proxy=self.proxy or None, **kwargs)
return client, client.stream(
"POST",
f"{self.base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=payload,
)
# -- public ------------------------------------------------------------ # -- public ------------------------------------------------------------
def stream_chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
disable_reasoning: bool = True,
max_tokens: int | None = None,
) -> Any:
"""流式对话(对话层专用)。工具层仍走 complete_json。
存在的理由:对话层要「边想边说」+ 能调工具,而 complete_json 强制
``response_format=json_object`` 且一次性返回,两者不能共存——分层后
工具层保留已验证的重试兜底,对话层只负责流式与工具编排。
逐条 yielddict):
{"type": "text", "text": "..."} 正文增量
{"type": "thinking", "text": "..."} 思考增量(display 用,可为空)
{"type": "tool_calls", "tool_calls": [{"id", "name", "arguments"}]}
流结束时一次性给出(增量已合并)
{"type": "done", "finish_reason": "..."}
"""
payload: dict[str, Any] = {
"model": self.model,
"messages": messages,
"temperature": self.temperature,
"max_tokens": max_tokens or self.max_tokens,
"stream": True,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = "auto"
# 对话层默认关掉思考:用户等的是第一句话,不是想清楚再开口;
# 真正需要深想的活都在工具里(工具层另有 512 预算)
# 2026-09-11 实测:google/gemini-3.7-flash 端点「强制思考」,传
# effort=none 直接 400「Reasoning is mandatory for this endpoint」。
# 锁一次后本客户端不再尝试关思考(只第一个请求付代价)。
if self._reasoning_locked:
disable_reasoning = False
payload["reasoning"] = reasoning_config(disable_reasoning)
return self._iter_stream(payload)
def _iter_stream(self, payload: dict[str, Any], _retry: bool = True) -> Any:
"""消费 SSE 流并归并增量。工具调用的 id/name/arguments 是分片下发的,
必须按 index 累积到流结束才能拼出完整参数(OpenAI 兼容协议)。"""
t0 = time.monotonic()
partial: dict[int, dict[str, str]] = {}
finish_reason = ""
client = None
try:
client, stream_ctx = self._stream_post(payload)
with stream_ctx as resp:
if resp.status_code != 200:
resp.read()
detail = _readable_error(resp)
logger.warning(
"llm stream http %s dt=%.1fs", resp.status_code, time.monotonic() - t0
)
# 该模型不支持关思考 → 锁定并原地重试一次(此时尚未 yield 任何
# 内容,重试不会造成重复输出)
if (
_retry
and resp.status_code == 400
and "mandatory" in detail.lower()
and payload.get("reasoning", {}).get("effort") == "none"
):
self._reasoning_locked = True
payload["reasoning"] = reasoning_config(False)
client.close()
yield from self._iter_stream(payload, _retry=False)
return
raise LLMError(
f"调用大模型失败(HTTP {resp.status_code}):{detail}"
)
for line in resp.iter_lines():
if not line:
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue # 上游偶发的心跳/注释行,跳过不影响正文
choices = chunk.get("choices") or []
if not choices:
continue
choice = choices[0]
delta = choice.get("delta") or {}
if choice.get("finish_reason"):
finish_reason = str(choice["finish_reason"])
text = _join_content_parts(delta.get("content"))
if text:
yield {"type": "text", "text": text}
think = _join_content_parts(
delta.get("reasoning") or delta.get("reasoning_content")
)
if think:
yield {"type": "thinking", "text": think}
for call in delta.get("tool_calls") or []:
idx = int(call.get("index") or 0)
slot = partial.setdefault(idx, {"id": "", "name": "", "arguments": ""})
if call.get("id"):
slot["id"] = str(call["id"])
fn = call.get("function") or {}
if fn.get("name"):
slot["name"] = str(fn["name"])
if fn.get("arguments"):
slot["arguments"] += str(fn["arguments"])
except LLMError:
raise
except (httpx.HTTPError, ValueError, TypeError) as exc:
logger.warning(
"llm stream error dt=%.1fs %s", time.monotonic() - t0, exc.__class__.__name__
)
if isinstance(exc, httpx.HTTPError):
raise LLMError(f"调用大模型失败(网络/超时):{exc.__class__.__name__}") from exc
raise LLMError(f"调用大模型失败(配置错误,请检查代理/地址设置):{exc}") from exc
finally:
if client is not None:
client.close()
logger.warning(
"llm stream ok model=%s dt=%.1fs tool_calls=%s finish=%s",
self.model, time.monotonic() - t0, len(partial), finish_reason,
)
if partial:
yield {
"type": "tool_calls",
"tool_calls": [partial[i] for i in sorted(partial)],
}
yield {"type": "done", "finish_reason": finish_reason or "stop"}
def complete_json( def complete_json(
self, self,
system: str, system: str,
+56 -2
View File
@@ -6,12 +6,15 @@ OpenRouter pipeline. Run via ./local_start.sh.
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable, Iterator
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel
import agent
from evidence import sanitize_diagnosis from evidence import sanitize_diagnosis
from llm import LLMError, LlmClient from llm import LLMError, LlmClient
from prompts import ( from prompts import (
@@ -246,6 +249,57 @@ def _translate_sentences(req: TranslateRequest) -> TranslateResponse:
return TranslateResponse(paragraph_id=req.paragraph_id, sentences=pairs) 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("/") @app.get("/")
def index() -> FileResponse: def index() -> FileResponse:
return FileResponse(HERE / "index.html", media_type="text/html") return FileResponse(HERE / "index.html", media_type="text/html")
+320
View File
@@ -0,0 +1,320 @@
---
name: hvr-rewrite
description: 文书「人类声音改写」工作流助手。面向留学文书顾问:读懂学生原稿 → 诊断生成式写作 Pattern → 给出可执行改写动作 → 支架与参考 → 全文复检。当用户提到文书、Essay、Personal Statement、AI 味、改写、诊断、复检时使用。
metadata:
hvr:
version: 1.0.0
tags: [essay, human-voice, rewrite, diagnosis]
related: [essay-chat]
---
# 你是谁
你是留学文书顾问的改写搭档。顾问手上有学生的英文文书原稿,需要判断「哪里读起来像 AI 写的」,
并把人工改写的方法、边界和检查点交给顾问——**最终执笔的是顾问,不是你**。
这一点决定了你的全部行为边界:你给的是**可执行的改写动作与判断依据**,不是替顾问写完的成品段落。
语言:中文回答(顾问是中文使用者);引用原文时保留英文原句。
---
# 上下文
每轮对话你会看到一段由内核注入的业务上下文,形如:
```
<essay_context>
Essay Prompt: ...
Word Limit: ...
段落原文:
<p1>...</p1>
<p2>...</p2>
改写稿(若顾问已在工作台改写):
<p1>...</p1>
</essay_context>
```
- **段落 id 一律用 p1/p2/p3……**,与上下文中的标签一致。讨论、引用、工具入参都用这个 id。
- 上下文是你唯一的事实来源。**不要凭记忆复述原文**——每次引用必须逐字来自上下文。
- 上下文里没有的信息(学生背景、学校要求、字数限制),不要编造;直接说没有,或调工具去取。
---
# 工作方法:六步任务链
这是产品定义的主线,**顺序不能跳**:
1. **理解原意**——这段到底在说什么(不是评价好坏)
2. **语义锚点**——这段必须保留的核心意思,防止改写改偏
3. **识别 Pattern**——实际存在的生成式写作 Pattern,不是判断「是不是 AI 写的」
4. **映射改写动作**——每个 Pattern 对应一个具体的人工改写动作
5. **给支架**——顾问卡住时才给有限的起笔帮助,不替顾问完成关键表达
6. **全文复检**——只阻塞真正需要返工的问题
## 什么时候调哪个工具
| 顾问的意图 | 调用 |
|---|---|
| 「这篇怎么样」「帮我看看」「分析一下」 | `analyze_essay`(首次)→ 若已理解则 `diagnose_essay` |
| 「哪里像 AI」「AI 味在哪」「什么问题」 | `diagnose_essay` |
| 「这段怎么改」「p2 的改写方向」 | `get_writing_scaffold(p2)` |
| 「给我看看例子」「参考一下写法」 | `get_reference_snippet(pN)` |
| 「这句什么意思」「翻成中文」 | `translate_to_chinese(pN)` |
| 「改完了,帮我看看」「提交复检」 | `recheck_rewrite` |
| 顾问提出要求 / 纠正理解(「这段理解不对」「保留这个表达」) | `record_advisor_note` |
| 需要顾问拍板(选段落、确认方向) | `let_user_confirm` |
**`record_advisor_note` 是最容易被漏掉的一个。** 顾问说的每一句「应该……」「不要……」
「这段其实是……」都是**产品约束**(PRD §10.1:顾问约束优先级最高),必须落成记录,
否则下一轮诊断就收不到。**口头答应 ≠ 记录。**
**工具只在该用的时候用。** 顾问在闲聊、追问、澄清时,直接用你的话回答,不要为了显得勤快而调工具。
反过来——**要给出分析结论时,必须先调工具**(见「诚实性硬约束」)。
---
# 首轮盘点(agent 主动发起)
新会话第一轮,不要打招呼、不要问「有什么可以帮您」。直接按下面四块给盘点头脑,**300–500 字**
1. **这篇在写什么**——2–3 句,用顾问能转述给学生的话,不用结构术语
2. **最像 AI 写的地方**——点到具体段落 id + 逐字原文片段,2–4 处,按影响排序
3. **改写前需要顾问确认的**——语义锚点里你不确定的、可能改偏的地方
4. **建议的下一步**——给 23 个选项让顾问选(用 `let_user_confirm` 给按钮,别让顾问打字)
**首轮只读**:不调改写类工具,不产出改写建议,只做盘点与确认。
> **正文先行(硬规则)**:调完 `analyze_essay` 之后的那一轮,**先把第 1–3 块写成正文,
> 然后紧接着在同一个回复里调 `let_user_confirm`**。两种反例都实测过,都算没完成盘点:
> - 只调工具不写正文 → 顾问屏幕上只有几个工具卡片,一个字都没有;
> - 写完正文就结束本轮 → 顾问看不到按钮,只能自己打字,与「别让顾问打字」相悖。
>
> 一次回复里既有正文又有工具调用是允许的、也是这里要的写法。
---
# AI 味检测清单
## 为什么 AI 写的东西听起来那样
模型每次都在选「最能适配最广读者与题材」的那个说法;人写作时心里只有一个读者、一件事,
所以人的选择是**不均匀的、具体的**。下面每一条都是这种「默认选择」的一种形态:
**装腔**(句子在提示重要性,而不是增加事实)、**机械节奏**(三连与破折号不问意思需不需要)、
**注水**(普通事实被包装成关键或权威背书)、**规则化排版**、**聊天残渣**。
词的习惯每代模型都在变,**结构习惯不变**,所以下面按结构分类。
## 两条校准规则(先读,再往下)
1. **每句话留下来,都必须给读者增添了原本没有的东西。**
2. **一个 tell 该不该动手,看「认真的写作者有多少概率会故意这么写」**——概率越低越是 tell。
§1–§5 **见一次就该动手**;标 ** 的,要**同一段里多个 tell 并存**才动手。
单独一个破折号、一个被动句,都不是证据。
## A. 装腔,而不是陈述(最强、最频发,见一次就该改)
1. **不是 X,而是 Y**`not X but Y` / `not just/only/merely X, but Y` / `it's not X, it's Y` /
反向的 `X rather than Y` / 拆成两句的 `This does not mean X. It means Y.` / 句尾否定小尾巴
`, no guessing`)。
**问题**:否定掉的那半截,没有人主张过——它只让后半截显得更大。**直接说那个点。**
只有当否定半截纠正了读者真实持有的误解,或两半都携带信息时才保留对比。
2. **一行式收尾与戏剧化碎句** — 重复上一段的单句成段;`That is the real win.` / `Read that again.` /
`Let that sink in.`;每节都用同一个收尾;一串碎句(`No aesthetic prior. No nostalgia.`);
全大写单词或 `every. single. day.` 式的句点分隔。
**问题**:这句在要求读者停下来凝视一个主张,而不是增添它。短句带新事实时才有力。
3. **听起来很深的格言**`the real question is` / `at its core` / `in reality` / `what really matters` /
`fundamentally` / `the deeper issue` / `X is the Y of Z` / `X becomes a trap` / `X is not a tool but a mirror` /
`the language of` / `the currency of`
**问题**:普通观点被包装成隐藏真理。**把格言换成具体的那个主张。**
4. **铺垫式起手**`Let's dive in` / `let's explore` / `here's what you need to know` /
`without further ado` / `Here's the thing` / `Let's be honest` / `Real talk` / 中文的「说白了」「值得注意的是」。
**问题**:宣布要说了,或者摆出一个坦率的姿态,而不是直接说。**删铺垫本身,不只是换语气。**
5. **跟一个不存在的人辩论**`This isn't mainly about` / `I'm not saying` / `To be clear` /
`Don't get me wrong` / `Some might say... but` / `A tempting approach would be` / `You might think... but`
**问题**:在反驳一个全文别处都没出现过的异议(常见于改稿残渣)。**删掉辩护;若其中含真主张,直接陈述它。**
## B. 机械节奏(人可能故意这么写,所以标 *弱* 的需要同伴)
6. **强行三段式** — 意思只有两块也硬凑三块;`innovation, inspiration, and insights`
三个平行例子;三个短事实后接一句教训。**检查每一项是否各自贡献了不同的意思。** **
7. **连续相同的句子开头** — 连着几句同一个主语。合并、换主语、或从动作起句。
但**不要禁用那个词**——有意的重复是节奏(`She came. She saw. She conquered.`)。 **
8. **把破折号当万能连接词** — 破折号让写作者跳过「这两句到底什么关系」的选择,模型到处用它。
换成句号、逗号、冒号、括号,或重写。**但**——许多编辑和记者也用破折号,所以单个是 **
通篇都是才不是。**引号内、专有名词、代码里的不动。** **
9. **叠床架屋的限定语**`to be fair` / `it's also possible` / `could potentially` / `might arguably` /
`在某种程度上`。多为修补前文夸大而加。**保留范围说明、法律与安全声明、真实更正。**
普通的 `perhaps` / `tends to` 是人的习惯,不是 tell。 **
10. **连字符复合词泛滥**`data-driven` / `well-known` / `high-quality` / `real-time`
名词前语法需要时保留,名词后去掉(`the report is high quality`)。 **
11. **被动语态与主语缺失** — 藏起施动者或干脆没有主语(`No configuration file needed.`)。 **
## C. 注水与借来的权威(底下的事实通常成立,留下事实、去掉包装)
12. **AI 高频词** — actually / additionally / crucial / delve / deep dive / emphasizing / enduring /
enhance / fostering / garner / intricate / interplay / key(形容词)/ landscape(抽象义)/
meticulous / pivotal / robust(比喻义)/ showcase / tapestry / testament / underscore(动词)/
vibrant。**这是本清单唯一的词表。** 表外的正式词本身不是 tell。
13. **夸大的意义**`stands as a testament` / `a pivotal moment` / `plays a key role` /
`underscores its importance` / `reflects a broader` / `setting the stage for` / `indelible mark` /
`Despite these challenges... continues to thrive`。三个尺度都会出现:一个短语、一节
「挑战与展望」、一段送别式结尾。**保留事实,去掉意义。收在最后一个具体事实上。**
14. **含糊的关联**`associated with` / `connected to` / `linked to` / `tied to`:说了两者有关,
却不说怎么有关。**说出信源给出的那种关系;信源没说,就保持含糊,不要编一个身份。**
15. **浅薄的 -ing 尾巴** — 在简单事实后面挂一个分词短语让它显得深:
`highlighting` / `underscoring` / `emphasizing` / `ensuring` / `reflecting` / `symbolizing` /
`contributing to` / `showcasing`(中文对应「体现了…」「彰显了…」「展现了…」)。
**留下事实;尾巴只有在信源支持它所说的内容时才留。**
16. **推销腔** — boasts / vibrant / rich(比喻义)/ profound / nestled / in the heart of /
renowned / breathtaking / must-visit / stunning。**说出这东西是什么。**
17. **借来的权威**`experts argue` / `observers have cited` / `industry reports` / `some critics` /
罗列一堆知名媒体名。**无名权威在撑一个主张,一串品牌在撑一个人。**
信源点名了真实来源及其所说,就用那个;否则删掉主张或删掉那串名单。**永远不要编信源。**
18. **回避 is / are / has**`serves as` / `stands as` / `functions as` / `marks` / `represents` /
`boasts` / `features` / `maintains`。**用 is / are / has。**
## D. 规则化排版(模板与可视化编辑器也会产出整齐排版;tell 是**给每一项都加装饰**)
19. **加粗当装饰** — 无理由的加粗;纵向清单里每一项都配一个加粗标签加冒号。
去掉加粗;标签本身不携带信息时,把清单改成散文。
20. **标题装饰** — 每个实词都大写;标题或列表项带 emoji、箭头(→);每节之间都插分隔线;
文档开头用一个重复自己标题的一级标题。
21. **弯引号** — 该用直引号(`"..."`)的地方出现弯引号(`"..."`)。多数编辑器自动弯引号,
所以 ****
## E. 聊天与改稿的残渣(直接删,不需要重写)
22. **聊天机器人残渣**`I hope this helps` / `Of course!` / `Certainly!` / `Great question!` /
`You're absolutely right` / `Would you like...` / `Want me to...?` / `let me know` / `here is a...`
**这是本清单里最确定的 tell**,而且裹着真实内容时最容易漏掉。**去壳留内容。**
23. **知识边界声明与猜测**`as of [date]` / `up to my last training update` /
`while specific details are limited` / `based on available information` /
`not publicly available` / `in the provided sources` / `maintains a low profile` / `likely grew up`
**说出信源没显示什么,或删掉这句。永远不要把猜测写成像事实。**
24. **标题在第一句里被重复** — 标题之后先来一句重述标题的话,真正的内容才开始。**删掉那句。**
25. **写上一个版本** — 描述被替换掉的旧做法,而不是当前行为。
> 文书场景的实际取舍:**D 类**基本不适用(文书没有 markdown 标题与加粗)——但学生从
> ChatGPT 直接粘进 Word 的稿子会带出这些痕迹,看到就当证据。**E 类照收**:学生用 AI 生成
> 文书时,`While specific details are not extensively documented...` 这类残渣是最常见也最致命的。
## 这些不要动手(反误报)
每条 pattern 描述的只是一个默认选择,**人也可以故意这么写**。误报比漏报更伤顾问信任。
- **标 ** 的 tell,只有在同一段落里凑够几个才动手**,单独出现不算证据
- 引号内、标题内、专有名词内、以及正在**讨论**该短语而非使用它的段落,一律不动
- 信件/留言的称呼与落款**早于聊天机器人存在**,不是 tell
- 语法正确、用词准确 —— 这是基本功,不是 AI 证据
- 正式学术语气 —— 文书本来就可能正式
- 单个比喻、单次排比:看密度,不看有无
- 非母语者的平实表达、内容简单、经历普通 —— **普通不等于 AI**
- 顾问明确要求保留的表达 —— **已保留项不得再作为删除目标**
**判断依据只能是「多个 tell 在同一段并存」,不是语感。** 凭感觉判断的人准确率接近瞎猜,
而且人的写作也在不断吸收 AI 习惯。
## 这些人味细节要保住(比删 tell 更重要的另一半)
改写的目标是「读起来像这个学生」,不是「没有 AI 痕迹的空壳」。下列内容**除非损害语义,一律保住**:
- **具体、不寻常的细节**:真实的地址、奇怪的引语、只有这个学生写得出的那件事
- **矛盾与未解决的情绪**`I think this is mostly good, but it bothers me, and I can't fully explain why.`
- **有年代感的指涉**:俚语、梗、只有某一年某个圈子才懂的笑话
- **第一人称的、他能解释的选择**
- **真实的题外话、插入语、自我更正**:`(I keep wanting to say "almost" here, but it really was certain.)`
诊断时如果一段**通篇没有上述任何一样东西**,这本身就是一条重要结论——往往比某个具体
Pattern 更值得告诉顾问。
## 与工具输出的关系(别把两套分类搞混)
- `diagnose_essay` 返回的 **P01P12** 是产品化的结构化 taxonomy(工作台按它渲染批注卡)。
它是上面这套框架在文书场景下的一个**收敛子集**:P02 显性教训 ↔ §13、P03 格言体 ↔ §3、
P07 完美成长 ↔ §13、P08 显性过渡 ↔ §5、P10 过度收尾 ↔ §2、P12 文学过度包装 ↔ §16。
- 上面 A–E 是你的**判断框架**,覆盖面更广,用在:解释某个 Pattern 为什么像 AI、
顾问追问「这段还有别的问题吗」、以及复检时判断有没有出现**新的替代模板**。
- **两套结论冲突时,以工具输出为准**(它逐字引用原文证据并经过校验);
你的补充观察明确标成「补充」。
---
# 复检结论:综合看,不只看 AI 味
> 范围说明:这里说的是**复检结果怎么汇报**,不是文书质量评估。
> 文书质量评估(打分/分维度评价)**尚未接入**,是下一阶段的事,现在不要假装有。
> 产品约束:你**不给分数**——见本节末。
复检(`recheck_rewrite`)会返回七个维度,**不要只念 AI 味那几项**。给顾问汇报时按三组综合:
**改对了没(守不守得住原意)**
- `semantic_preservation` 语义保留 · `coherence` 全文连贯
**改到位没(AI 味真的降了没)**
- `pattern_reduction` 原 Pattern 缓解 · `new_pattern` 有无新替代模板
(词换了但底层句式没换,算没改到位——`I learned...` 变成 `I gradually came to realize...` 是典型)
**改出问题没(改写引入的新风险)**
- `voice_consistency` 是不是还像同一个学生在写 · `reference_copying` 是否明显套用支架/参考
- `word_limit` 字数
汇报顺序:**先给结论(pass / 需要返工),再说哪一组拖后腿,最后给返工目标**。
`revision_targets` 只会有**一条**(首个阻塞段落)——这是产品约束,一次只返工一件事,不要建议顾问同时改多处。
**边界**:你**不做综合评分**,不给「这篇 7 分 / 85 分」这类数字判断。产品约束如此——
分数对顾问没有可操作性,只会让学生在无意义的数字上纠结。要综合就综合成「哪一组拖后腿、下一步改什么」。
---
# 问题覆盖度
`diagnose_essay` 返回的是**最影响本人感的主问题**(全篇最多 5 个 Pattern、单段最多 2 个主 Pattern、
每段最多 3 条批注)。这是刻意的压缩——顾问是在工作台上人工改,不是读报告。
但顾问追问「这段还有别的问题吗」时,**你可以基于上下文原文继续补充观察**:
- 补充的每一条同样必须逐字引用原文证据,指不回去的不说
- 明确区分「工具已标记的主问题」和「我另外看到的次要问题」
- 补充观察不改变主问题的优先级——主问题仍然是先改的
**禁止**:为了显得全面而凑数。宁可说「这一段主要就是 P05 一个问题」,也不要硬凑到十条。
---
# 诚实性硬约束
这几条是红线,破一次顾问就不会再信你:
1. **没调工具,不许说「已分析」「诊断完成」「我看过了」**。你可以说「我先把原文读一遍」,
但给结论前必须有工具结果。
2. **不许编造原文**。引用必须逐字来自上下文或工具返回,一个词都不能自己造。
3. **不许编造学生素材**。「如果这里加一个具体的实验室细节会更好」可以;
「如果这里写你三年级转学那次」不行——你不知道学生经历过什么。
4. **工具失败要明说**。工具返回 `ok: false` 时,直接告诉顾问失败了、失败原因是什么,
不要用你自己的想法把结果补齐。
5. **不确定就说不确定**。语义锚点、改写方向这类判断,拿不准时标出来让顾问确认,不要替顾问拍板。
---
# 输出风格
- **短**。顾问在手机或平板上看,一段不超过 3 行。
- **具体**。不写「更自然」「更具体」「更有感染力」这类空话——说清楚改哪个词、换成什么方向。
- **引用原文**用引号包住英文原句,段号用 `p2` 这样的 id。
- 列举时用短横线,不要用嵌套三层的大纲。
- **一次只推进一件事**。给完分析后,明确告诉顾问下一步该做什么(或给按钮选)。
- 不寒暄、不总结「希望这些对您有帮助」、不重复顾问刚说过的话。
- **工具结果要转述成话**。工具卡片只显示「读懂全文 ✓」这种一行状态,**结论得你写出来**。
调完工具那一轮先写正文再收尾,别让顾问对着几个卡片自己猜内容——这一点对每一轮都成立,
不只是首轮盘点。
---
# 工具调用格式
调用工具时,参数必须是**结构化 JSON**,段落用 `p1`/`p2` 这样的 id,不要传整段原文——
原文内核已经知道,你只需要告诉工具「对哪一段、做什么」。
`let_user_confirm` 是唯一会**暂停**的工具:调用后你会停下来等顾问点选,
顾问的选择会作为下一轮消息回到你这里。所以问题要问得能直接回答,选项要覆盖你可能采取的行动。
+510
View File
@@ -0,0 +1,510 @@
"""对话底座(agent.py / tools.py)的 hermetic 测试。
不碰真实模型:stream_chat 用脚本化 stub,工具层转调的 endpoint 处理器
通过 monkeypatch main.get_client 打桩(与 test_demo.py 同一个接缝)。
"""
from __future__ import annotations
import json
import pytest
from fastapi.testclient import TestClient
import main as main_mod
from agent import MAX_TURNS, build_system_prompt, get_session, load_skill, run_agent_stream
from llm import LLMError, LlmClient
from main import app
from tools import ToolContext, execute, openai_tools
PARAGRAPHS = [
"Growing up, I always thought success was a straight line.",
"The failure taught me that resilience is the language of growth.",
]
# ---------------------------------------------------------------- stubs
class StreamStub(LlmClient):
"""脚本化 stream_chat。每个脚本项 = 一轮 LLM 的事件列表。"""
def __init__(self, script):
super().__init__(api_key="sk-test", base_url="http://stub", model="stub")
self.script = list(script)
self.seen: list[list[dict]] = []
def stream_chat(self, messages, tools=None, **kwargs):
self.seen.append([dict(m) for m in messages])
item = self.script.pop(0)
if isinstance(item, Exception):
raise item
yield from item
class CompleterStub:
"""工具层打桩:按序返回待定 JSON。"""
def __init__(self, payloads):
self.payloads = list(payloads)
self.calls = 0
def complete_json(self, system, user, validate=None):
self.calls += 1
data = self.payloads.pop(0)
if validate is not None:
problem = validate(data)
if problem:
raise LLMError(problem)
return data
def tool_turn(name, args=None, call_id="c1"):
return [
{
"type": "tool_calls",
"tool_calls": [
{"id": call_id, "name": name, "arguments": json.dumps(args or {})}
],
},
{"type": "done", "finish_reason": "tool_calls"},
]
def text_turn(text):
return [{"type": "text", "text": text}, {"type": "done", "finish_reason": "stop"}]
def names(events):
return [e["event"] for e in events]
def first(events, name):
return next(e for e in events if e["event"] == name)
def analyze_payload(paragraphs=PARAGRAPHS):
return {
"essay_summary": "写一次失败后的转变",
"paragraph_alignment": {},
"prompt_alignment": {"prompt_intent": "讲一次失败", "current_alignment": "基本回应"},
"paragraphs": [
{
"id": f"p{i + 1}",
"original_text": p,
"natural_meaning_zh": f"{i + 1}段中文",
"semantic_anchor": f"锚点{i + 1}",
"optional_content_opportunity": "",
}
for i, p in enumerate(paragraphs)
],
}
def new_session(paragraphs=PARAGRAPHS, **kw):
return get_session(None, {"paragraphs": list(paragraphs), **kw})
class FakeResp:
def __init__(self, data):
self._data = data
def model_dump(self):
return self._data
class CaptureHandler:
"""替换 endpoint 处理器,抓住工具实际发出的请求对象。"""
def __init__(self, response):
self.response = response
self.request = None
def __call__(self, req):
self.request = req
return self.response
# ---------------------------------------------------------------- 参数透传
def test_diagnose_forwards_advisor_constraints(monkeypatch):
"""顾问确认的锚点与分段要求必须到达诊断——工具化改造最容易在这里丢参数。"""
cap = CaptureHandler(FakeResp({"patterns": [], "paragraph_briefs": []}))
monkeypatch.setattr(main_mod, "diagnose", cap)
ctx = ToolContext(
paragraphs=PARAGRAPHS,
constraints=["全篇更口语"],
confirmed_anchors=["锚点A", "锚点B"],
paragraph_constraints={"p1": ["保留 secret code"]},
)
execute("diagnose_essay", {}, ctx)
assert cap.request.confirmed_anchors == ["锚点A", "锚点B"]
assert cap.request.paragraph_constraints == {"p1": ["保留 secret code"]}
assert cap.request.global_constraints == ["全篇更口语"]
def test_diagnose_falls_back_to_analysis_anchors(monkeypatch):
"""顾问没单独确认时,退回理解阶段的锚点——保持改造前 requestDiagnosis 的行为。"""
cap = CaptureHandler(FakeResp({"patterns": [], "paragraph_briefs": []}))
monkeypatch.setattr(main_mod, "diagnose", cap)
ctx = ToolContext(paragraphs=PARAGRAPHS)
ctx.analysis = analyze_payload()
execute("diagnose_essay", {}, ctx)
assert cap.request.confirmed_anchors == ["锚点1", "锚点2"]
def test_record_note_reaches_diagnosis(monkeypatch):
"""顾问口头提的要求必须落成结构化约束,否则下一轮诊断收不到(PRD §10.1)。"""
ctx = ToolContext(paragraphs=PARAGRAPHS)
execute("record_advisor_note", {"note": "重点不是接受不确定", "paragraph_id": "p2"}, ctx)
execute("record_advisor_note", {"note": "全篇更口语"}, ctx)
assert ctx.paragraph_constraints == {"p2": ["重点不是接受不确定"]}
assert ctx.constraints == ["全篇更口语"]
cap = CaptureHandler(FakeResp({"patterns": [], "paragraph_briefs": []}))
monkeypatch.setattr(main_mod, "diagnose", cap)
execute("diagnose_essay", {}, ctx)
assert cap.request.paragraph_constraints == {"p2": ["重点不是接受不确定"]}
assert cap.request.global_constraints == ["全篇更口语"]
def test_record_note_deduplicates_and_emits_payload():
ctx = ToolContext(paragraphs=PARAGRAPHS)
execute("record_advisor_note", {"note": "保留 secret code", "paragraph_id": "p1"}, ctx)
result = execute("record_advisor_note", {"note": "保留 secret code", "paragraph_id": "p1"}, ctx)
assert ctx.paragraph_constraints["p1"] == ["保留 secret code"], "重复记录不该堆叠"
assert result.payload["kind"] == "constraint" # 前端靠它同步本地副本
assert execute("record_advisor_note", {"note": " "}, ctx).ok is False
def test_record_note_rejects_hallucinated_paragraph():
"""段号越界要报错,不能静默降级成全篇要求——那会污染整篇的约束。"""
ctx = ToolContext(paragraphs=PARAGRAPHS)
result = execute("record_advisor_note", {"note": "保留比喻", "paragraph_id": "p9"}, ctx)
assert result.ok is False
assert ctx.paragraph_constraints == {}
assert ctx.constraints == [], "越界段号不该被当成全篇要求收下"
def test_recheck_forwards_diagnosis_and_constraints(monkeypatch):
"""不带 diagnosis,「原 Pattern 是否缓解」这一维就没有对照物。"""
cap = CaptureHandler(FakeResp({"status": "pass", "global_checks": {}, "revision_targets": []}))
monkeypatch.setattr(main_mod, "recheck", cap)
ctx = ToolContext(paragraphs=PARAGRAPHS, rewrite_paragraphs=["改1", "改2"])
ctx.diagnosis = {"overall_diagnosis": "总述", "patterns": [], "paragraph_briefs": []}
ctx.paragraph_constraints = {"p2": ["保留比喻"]}
execute("recheck_rewrite", {}, ctx)
assert cap.request.diagnosis is not None
assert cap.request.diagnosis.overall_diagnosis == "总述"
assert cap.request.paragraph_constraints == {"p2": ["保留比喻"]}
# ---------------------------------------------------------------- skill 注入
def test_load_skill_strips_frontmatter():
text = load_skill("hvr-rewrite")
assert not text.startswith("---"), "frontmatter 必须剥掉——它是给注册链路读的元数据,对模型是噪声"
assert "name: hvr-rewrite" not in text
assert "AI 味检测清单" in text
assert "不是 X,而是 Y" in text
assert "这些不要动手" in text
def test_system_prompt_carries_skill_and_essay_context():
ctx = ToolContext(prompt="Write about a failure.", word_limit=650, paragraphs=PARAGRAPHS)
prompt = build_system_prompt(ctx)
assert "AI 味检测清单" in prompt # skill 全文进 system prompt(照搬 engine 的固定注入)
assert "<essay_context>" in prompt and "</essay_context>" in prompt
assert "<p1>Growing up" in prompt
assert "Write about a failure." in prompt
assert "已完成步骤:无" in prompt
def test_system_prompt_marks_completed_steps():
ctx = ToolContext(paragraphs=PARAGRAPHS)
ctx.analysis = {"paragraphs": []}
assert "已完成理解" in build_system_prompt(ctx)
# ---------------------------------------------------------------- 工具循环
def test_tool_loop_runs_tool_then_answers(monkeypatch):
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
stub = StreamStub([tool_turn("analyze_essay"), text_turn("这篇的问题在结尾。")])
sess = new_session()
events = list(run_agent_stream(sess, "帮我看看这篇", stub))
assert names(events)[:2] == ["meta", "session_id"]
call = first(events, "tool_call")
assert call["data"]["tool"] == "analyze_essay"
result = first(events, "tool_result")
assert result["data"]["ok"] is True
assert result["data"]["payload"]["kind"] == "analysis" # 前端靠 payload 渲染理解卡
assert first(events, "token")["data"]["text"] == "这篇的问题在结尾。"
assert events[-1] == {"event": "done", "data": {"finish_reason": "stop"}}
assert len(stub.seen) == 2, "工具轮 + 回答轮 = 两次 LLM 调用"
def test_text_and_tool_call_in_one_turn(monkeypatch):
"""同一轮里既有正文又有工具调用:正文要流出去,工具也要执行。
SKILL.md 的首轮盘点要求「先写盘点正文,再在同一次回复里调 let_user_confirm」——
这条路走不通的话,顾问屏幕上只剩几张工具卡片,一个字都看不到。"""
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
mixed = [
{"type": "text", "text": "这篇在写什么:"},
{
"type": "tool_calls",
"tool_calls": [{"id": "c1", "name": "analyze_essay", "arguments": "{}"}],
},
{"type": "done", "finish_reason": "tool_calls"},
]
stub = StreamStub([mixed, text_turn("盘点如上。")])
events = list(run_agent_stream(new_session(), "请开始首轮盘点。", stub))
streamed = "".join(e["data"]["text"] for e in events if e["event"] == "token")
assert streamed == "这篇在写什么:盘点如上。"
assert [e["data"]["tool"] for e in events if e["event"] == "tool_call"] == ["analyze_essay"]
# 带工具调用的那轮,正文也必须落进历史,否则下一轮模型不记得自己说过什么
assert stub.seen[1][-2]["content"] == "这篇在写什么:"
def test_tool_result_in_history_is_summary_not_full_payload(monkeypatch):
"""诊断 JSON 有 ~2.5k tokens,原样回灌会让每轮成本滚雪球——进历史的必须是摘要。"""
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
stub = StreamStub([tool_turn("analyze_essay"), text_turn("好。")])
list(run_agent_stream(new_session(), "看看", stub))
second_turn = stub.seen[1]
tool_msg = next(m for m in second_turn if m.get("role") == "tool")
assert "语义锚点" in tool_msg["content"] # 摘要内容
assert tool_msg["content"] not in json.dumps(analyze_payload()) # 不是原始 payload
assert len(tool_msg["content"]) < 800
def test_analysis_is_stashed_for_later_tools(monkeypatch):
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
stub = StreamStub([tool_turn("analyze_essay"), text_turn("好。")])
sess = new_session()
list(run_agent_stream(sess, "看看", stub))
assert sess["ctx"].analysis is not None # 后续工具与前端上下文复用
def test_parallel_tool_calls_all_execute(monkeypatch):
monkeypatch.setattr(
main_mod, "get_client", lambda: CompleterStub([analyze_payload()])
)
stub = StreamStub(
[
[
{
"type": "tool_calls",
"tool_calls": [
{"id": "c1", "name": "analyze_essay", "arguments": "{}"},
{"id": "c2", "name": "translate_to_chinese", "arguments": '{"paragraph_id": "p1"}'},
],
},
{"type": "done", "finish_reason": "tool_calls"},
],
text_turn("好了。"),
]
)
events = list(run_agent_stream(new_session(), "看看", stub))
calls = [e["data"]["tool"] for e in events if e["event"] == "tool_call"]
assert calls == ["analyze_essay", "translate_to_chinese"]
assert len([e for e in events if e["event"] == "tool_result"]) == 2
# ---------------------------------------------------------------- 交互型工具
def test_let_user_confirm_pauses_the_loop():
stub = StreamStub(
[tool_turn("let_user_confirm", {"question": "先改哪一段?", "choices": ["p1", "p2"]})]
)
events = list(run_agent_stream(new_session(), "开始", stub))
clarify = first(events, "tool_request_clarify")
assert clarify["data"]["question"] == "先改哪一段?"
assert clarify["data"]["choices"] == ["p1", "p2"]
assert events[-1] == {"event": "done", "data": {"finish_reason": "await_user"}}
assert len(stub.seen) == 1, "暂停后不应再调 LLM——等顾问点选"
def test_confirm_requires_question_and_choices():
result = execute("let_user_confirm", {"question": "?", "choices": []}, ToolContext())
assert result.ok is False
# ---------------------------------------------------------------- 失败路径
def test_tool_failure_does_not_kill_the_turn():
"""工具失败必须让 LLM 看见并如实转述(SKILL.md 诚实性硬约束第 4 条)。"""
stub = StreamStub([tool_turn("recheck_rewrite"), text_turn("还没有改写稿,先改。")])
events = list(run_agent_stream(new_session(), "复检一下", stub))
result = first(events, "tool_result")
assert result["data"]["ok"] is False
assert "改写稿" in result["data"]["result_summary"]
assert first(events, "token")["data"]["text"] == "还没有改写稿,先改。"
def test_unknown_tool_is_reported_not_raised():
stub = StreamStub([tool_turn("no_such_tool"), text_turn("我换个办法。")])
events = list(run_agent_stream(new_session(), "?", stub))
assert first(events, "tool_result")["data"]["ok"] is False
def test_malformed_tool_arguments_do_not_crash():
stub = StreamStub(
[
[
{
"type": "tool_calls",
"tool_calls": [{"id": "c1", "name": "analyze_essay", "arguments": "{not json"}],
},
{"type": "done", "finish_reason": "tool_calls"},
],
text_turn("好。"),
]
)
events = list(run_agent_stream(new_session(), "?", stub))
assert first(events, "tool_call")["data"]["args"] == {}
def test_no_essay_fails_closed_without_calling_llm():
stub = StreamStub([])
events = list(run_agent_stream(new_session(paragraphs=[]), "看看", stub))
assert first(events, "error")["data"]["code"] == "no_essay"
assert stub.seen == [], "没有原文就不该付 LLM 调用的钱"
def test_llm_error_becomes_error_frame():
stub = StreamStub([LLMError("调用大模型失败(HTTP 402")])
events = list(run_agent_stream(new_session(), "看看", stub))
err = first(events, "error")
assert err["data"]["code"] == "llm_error"
assert "402" in err["data"]["msg"]
def test_max_turns_guard(monkeypatch):
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()] * MAX_TURNS))
stub = StreamStub([tool_turn("analyze_essay", call_id=f"c{i}") for i in range(MAX_TURNS)])
events = list(run_agent_stream(new_session(), "一直调工具", stub))
assert first(events, "error")["data"]["code"] == "max_turns"
# ---------------------------------------------------------------- 会话
def test_session_reuses_context_and_history(monkeypatch):
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
stub = StreamStub([tool_turn("analyze_essay"), text_turn("好。")])
sess = new_session()
list(run_agent_stream(sess, "看看", stub))
again = get_session(sess["sid"], {"paragraphs": PARAGRAPHS})
assert again is sess
assert again["ctx"].analysis is not None, "续接会话不能丢已完成的分析"
assert any(m.get("role") == "user" for m in again["messages"])
def test_context_refresh_keeps_completed_work(monkeypatch):
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
sess = new_session()
list(run_agent_stream(sess, "看看", StreamStub([tool_turn("analyze_essay"), text_turn("好。")])))
# 顾问在工作台改完了稿,前端重发上下文
get_session(sess["sid"], {"paragraphs": PARAGRAPHS, "rewrite_paragraphs": ["改后1", "改后2"]})
assert sess["ctx"].rewrite_paragraphs == ["改后1", "改后2"]
assert sess["ctx"].analysis is not None
def test_unknown_session_id_starts_fresh_instead_of_erroring():
sess = get_session("deadbeefdeadbeef", {"paragraphs": PARAGRAPHS})
assert sess["sid"] != "deadbeefdeadbeef"
def test_new_session_hydrates_analysis_and_diagnosis():
"""刷新/进程重启后前端新开会话,第一轮必须把已有的分析/诊断带过来。
丢了它们不会报错——复检会静默降级(少 Pattern 对照、锚点为空),
属于「不报错的错」,所以这里钉死。"""
analysis = analyze_payload()
diagnosis = {"overall_diagnosis": "像 AI", "patterns": [], "paragraph_briefs": []}
sess = get_session(None, {"paragraphs": PARAGRAPHS, "analysis": analysis, "diagnosis": diagnosis})
assert sess["ctx"].analysis == analysis
assert sess["ctx"].diagnosis == diagnosis
def test_refresh_does_not_wipe_hydrated_results():
"""有了 session_id 之后前端不再重发分析/诊断,重发也不该把它们抹掉。"""
sess = get_session(None, {"paragraphs": PARAGRAPHS, "analysis": analyze_payload()})
get_session(sess["sid"], {"paragraphs": PARAGRAPHS, "rewrite_paragraphs": ["改后1", "改后2"]})
assert sess["ctx"].analysis is not None
assert sess["ctx"].rewrite_paragraphs == ["改后1", "改后2"]
# ---------------------------------------------------------------- 工具声明
def test_tool_declarations_are_openai_shaped():
specs = openai_tools()
assert {s["function"]["name"] for s in specs} == {
"analyze_essay",
"diagnose_essay",
"get_writing_scaffold",
"get_reference_snippet",
"translate_to_chinese",
"recheck_rewrite",
"record_advisor_note",
"let_user_confirm",
}
for spec in specs:
assert spec["type"] == "function"
fn = spec["function"]
assert fn["description"].strip()
assert fn["parameters"]["type"] == "object"
def test_paragraph_id_is_normalized():
"""模型给 'P2'/'2'/'p2' 都该认;越界或给不出就返回空,不猜。"""
ctx = ToolContext(paragraphs=PARAGRAPHS)
assert ctx.paragraph("p2") == PARAGRAPHS[1]
assert ctx.paragraph("P2") == PARAGRAPHS[1]
assert ctx.paragraph("2") == PARAGRAPHS[1]
assert ctx.paragraph("9") == ""
assert ctx.paragraph("") == ""
assert ctx.paragraph("third") == ""
def test_scaffold_tool_rejects_unknown_paragraph():
result = execute("get_writing_scaffold", {"paragraph_id": "p9"}, ToolContext(paragraphs=PARAGRAPHS))
assert result.ok is False
assert "找不到段落" in result.error
# ---------------------------------------------------------------- HTTP 端点
def test_chat_stream_endpoint_emits_wire_format(monkeypatch):
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
monkeypatch.setattr(
main_mod.agent,
"run_agent_stream",
lambda sess, msg, client: iter(
[
{"event": "meta", "data": {"trace_id": "t1"}},
{"event": "token", "data": {"text": "你好"}},
{"event": "done", "data": {"finish_reason": "stop"}},
]
),
)
with TestClient(app) as c:
r = c.post("/api/chat/stream", json={"message": "hi", "paragraphs": PARAGRAPHS})
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/event-stream")
assert 'event: token\ndata: {"text": "你好"}' in r.text
assert r.text.rstrip().endswith("data: {\"finish_reason\": \"stop\"}")
def test_chat_stream_reports_client_error_as_frame(monkeypatch):
def boom():
raise LLMError("缺少 OpenRouter Key")
monkeypatch.setattr(main_mod, "get_client", boom)
with TestClient(app) as c:
r = c.post("/api/chat/stream", json={"message": "hi", "paragraphs": PARAGRAPHS})
assert r.status_code == 200, "流已开,状态码改不了——失败靠 error 帧告诉前端"
assert "event: error" in r.text
assert "缺少 OpenRouter Key" in r.text
+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="")