diff --git a/agent.py b/agent.py
new file mode 100644
index 0000000..9855549
--- /dev/null
+++ b/agent.py
@@ -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] = [""]
+ 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"{text}
")
+ if ctx.rewrite_paragraphs:
+ lines.append("顾问在工作台的当前改写稿:")
+ for i, text in enumerate(ctx.rewrite_paragraphs, start=1):
+ lines.append(f"{text}
")
+ else:
+ lines.append("顾问在工作台的当前改写稿:(尚未开始改写)")
+ if ctx.constraints:
+ lines.append("顾问全局要求:" + ";".join(ctx.constraints))
+ lines.append("")
+
+ 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)
diff --git a/index.html b/index.html
index b84da7f..3dc40b9 100644
--- a/index.html
+++ b/index.html
@@ -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}
.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}
+/* 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}
.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,
lastRecheckSnapshot, lastRecheckResult, analysisVersion, agentStage,
chatLog, essayVersions, viewedVersion, recheckHistory, translations, translateOpen,
- origTranslations, origTranslateOpen
+ origTranslations, origTranslateOpen, chatSessionId
}));
return true;
}catch(e){ return false; }
@@ -559,6 +573,9 @@ function restoreSession(){
lastRecheckResult = s.lastRecheckResult || null;
analysisVersion = s.analysisVersion || 1;
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;
essayVersions = Array.isArray(s.essayVersions) ? s.essayVersions : [];
viewedVersion = essayVersions.some(v=>v.version===s.viewedVersion) ? s.viewedVersion : null;
@@ -646,29 +663,12 @@ async function runAnalyze(){
viewedVersion = null; // 新一轮以原版为工作底稿(已完成的版本 N 仍保留可切换)
ensureParaState();
track('human_voice_analysis_start', {paragraphs: paragraphs.length});
- show('agentView'); chat.innerHTML=''; chatLog=[]; agentStage='loading';
- // 统一走 addLoadingCard:带 45s/120s 超时提示(真实模型生成可能超过 1 分钟)
- const loading = addLoadingCard('正在读懂这篇文书…', ['正在结合题目理解全文','正在确认每一段实际表达的内容','正在整理需要与你确认的理解']);
- const rows = loading.querySelectorAll('.loading-row');
- const t1 = setTimeout(()=>{rows[0].classList.remove('active');rows[1].classList.add('active')},650);
- const t2 = setTimeout(()=>{rows[1].classList.remove('active');rows[2].classList.add('active')},1300);
- 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);
- }
+ show('agentView'); chat.innerHTML=''; chatLog=[]; chatSessionId=null;
+ agentStage='loading';
+ // 首轮不再直接打 /api/analyze:交给 agent 按 SKILL.md「首轮盘点」自己调 analyze_essay,
+ // 盘点结论 + 理解卡一起给出,并由它用 let_user_confirm 问顾问下一步。
+ // 改造前这里是一条固定流水线(分析 → 等确认 → 诊断),现在由 skill 驱动。
+ streamAgent('请开始首轮盘点。');
}
async function requestDiagnosis(){
@@ -681,29 +681,10 @@ async function requestDiagnosis(){
});
return resp;
}
-async function runDiagnose(opts){
- // 防重入:诊断请求进行中忽略重复触发(用户实测连点【理解准确,继续分析】——
- // 修复前两次并发请求会各渲染一张诊断卡并白花一次模型调用,见 Deviations #23)
- if(agentStage==='diagloading') return;
- 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));
- }
-}
+// 原先这里有一条对话页专用的 runDiagnose(带 loading 卡、跑 /api/diagnose 再渲染诊断卡)。
+// Agent 化之后诊断由 agent 调 diagnose_essay 工具触发,载荷经 applyToolPayload 走同一条
+// renderDiagnosis,这个函数已无调用点,删掉避免两套并行的诊断路径各自漂移。
+// 工作台的 submitConstraint 仍直接调 requestDiagnosis —— 那是工作台自己的路径,未改动。
function renderDiagnosis(){
const d = diagnosis;
@@ -754,10 +735,14 @@ function handleAction(action){
// 对话页与 Workbench 都能显示。
if(action==='view-recheck-history'){ renderRecheckHistory(); toggleRecheckHistory(); return; }
if(action==='confirm-understanding'){
- // 防重入:诊断请求进行中忽略连点(用户实测连点——修复前两次并发请求 +
- // 两个确认气泡 + 两张诊断卡,见 Deviations #23);runDiagnose 内另有同守卫兜底
- if(agentStage==='diagloading') return;
- addUser('理解准确,继续。');track('understanding_confirmed');runDiagnose();
+ // 防重入:这一轮 agent 还没跑完就忽略连点(原先防的是两次并发 /api/diagnose,
+ // 连点会各渲染一张诊断卡;现在连点会各起一条 SSE 流,等价问题)
+ if(agentBusy||agentStage==='diagloading') return;
+ track('understanding_confirmed');
+ addUser('理解准确,继续。');
+ // 不再直接调 runDiagnose():由 agent 自己决定下一步并调 diagnose_essay,
+ // 它要能在这条消息里同时处理「确认 + 顺带的补充要求」
+ streamAgent('顾问确认理解准确,请继续。');
}
if(action==='adjust-understanding'){const input=$('#chatInput');input.placeholder='例如:第三段重点不是接受不确定,而是更关注推理过程……';input.focus();}
// 每个按钮给出自己的占位提示再聚焦(只 focus 会把其它按钮的提示残留下来——用户实测):
@@ -785,18 +770,11 @@ function handleAction(action){
renderEntryEssay();
}
}
-function applyUnderstandingCorrection(text){
- const idx = parseParagraphIndex(text);
- if(idx===null || !analysis || !analysis.paragraphs[idx]){
- globalConstraints.push(text);
- return {scope:'global', label:'文章理解的补充'};
- }
- analysis.paragraphs[idx].natural_meaning_zh = text;
- analysis.paragraphs[idx].semantic_anchor = text;
- const pid = pidOf(idx);
- (paragraphConstraints[pid]=paragraphConstraints[pid]||[]).push(text);
- return {scope:pid, label:`${paragraphLabel(idx)} 的理解`};
-}
+// 原先这里是 applyUnderstandingCorrection:顾问在理解态说「第三段重点不是……」时,
+// 它把这句话同时写进 analysis.paragraphs[i] 的 natural_meaning_zh / semantic_anchor 和
+// paragraphConstraints。Agent 化之后这条输入走 record_advisor_note,只落成分段约束,
+// **锚点不再跟着改**(纠正内容仍会进诊断/复检的 paragraph_constraints)。
+// 这是已知偏差,不是遗漏:要不要补一个带 anchor 更新的「纠正理解」工具待定。
function applyDiagnosisConstraint(text){
const raw = text.trim();
const idx = parseParagraphIndex(raw);
@@ -826,30 +804,232 @@ function showInputHint(){
clearTimeout(hint._t);
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,'$1').replace(/`([^`\n]+)`/g,'$1');
+}
+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(`
${mdInline(para.join('
'))}
`); para=[]; } };
+ const flushList = ()=>{ if(items.length){ out.push(''+items.map(x=>`- ${mdInline(x)}
`).join('')+'
'); 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(`${mdInline(head[1])}
`); 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(`中文对照
${escapeHtml(pid||'')}
${escapeHtml(t)}
`));
+ }
+}
+function renderChoices(container, data){
+ const wrap = document.createElement('div');
+ wrap.className = 'quick-actions';
+ wrap.innerHTML = `${escapeHtml(data.question||'')}
` +
+ (data.choices||[]).map((c,i)=>``).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 = 'AI
';
+ 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', `${escapeHtml(failed)}
`);
+ 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 = `${escapeHtml(toolLabel(data.tool))}${escapeHtml(SLOW_TOOLS[data.tool]||'')}`;
+ 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 = `${escapeHtml(toolLabel(data.tool))}${escapeHtml(data.result_summary||'')}`;
+ }
+ 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){
if(!text.trim())return;
- if(agentStage==='loading'||agentStage==='diagloading'||agentStage==='recheckloading'){
+ if(agentBusy||agentStage==='loading'||agentStage==='diagloading'||agentStage==='recheckloading'){
showInputHint(); // 分析中:输入框内显示红色提示,已输入内容保留不清除
return;
}
addUser(text.trim());$('#chatInput').value='';
- if(agentStage==='understanding'){
- const applied = applyUnderstandingCorrection(text.trim());
- track('understanding_corrected', applied);
- persistNow();
- addAgent(`明白,我把这点记作${escapeHtml(applied.label)}。后面的 Diagnosis / Rewrite Guidance / Recheck 都会以你确认后的版本为准。
${escapeHtml(text.trim())}
如果各段整体理解没有其它问题,可以继续做人味分析。
`);
- bindQuickActions();
- }else if(agentStage==='diagnosis'){
- const normalized = applyDiagnosisConstraint(text.trim());
- persistNow();
- track('diagnosis_re_generated', {constraint: normalized});
- addAgent(`收到,已记入:
${escapeHtml(normalized)}
正在按新要求重新生成诊断,批注与各段提示将同步更新…
`);
- runDiagnose({regen:true});
- }else if(agentStage==='recheck'){
- addAgent(`复检结论已经给出。你可以返回对应段落继续修改后再次提交全文复检。
`);
- }else{
- addAgent(`请先点击「开始分析」,或返回文书页。
`);
- }
+ streamAgent(text.trim());
}
function fusedAiFocus(i){
@@ -1369,9 +1549,11 @@ function onRewriteBlur(){
updateParagraphSwitcherState(); // 不重建按钮:避免点击段号时 mousedown→blur 重建导致第一次点击丢失
updateProgress();
}
-function recordExposure(kind, text){
+function recordExposure(kind, text, pid){
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(!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)){
diff --git a/llm.py b/llm.py
index a141617..45fb05a 100644
--- a/llm.py
+++ b/llm.py
@@ -163,6 +163,8 @@ class LlmClient:
if not self.api_key:
raise LLMError("缺少 OpenRouter Key:请通过 local_start.sh 启动(从 prodream_backend/.env 读取)")
self._transport = transport
+ # 端点是否强制开思考(gemini-3.7-flash 是)——首次 400 后锁定,见 stream_chat
+ self._reasoning_locked = False
# -- internal ----------------------------------------------------------
def _post(self, payload: dict[str, Any]) -> httpx.Response:
@@ -237,7 +239,158 @@ class LlmClient:
)
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 ------------------------------------------------------------
+ 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`` 且一次性返回,两者不能共存——分层后
+ 工具层保留已验证的重试兜底,对话层只负责流式与工具编排。
+
+ 逐条 yield(dict):
+ {"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(
self,
system: str,
diff --git a/main.py b/main.py
index b325bd8..3f20fc8 100644
--- a/main.py
+++ b/main.py
@@ -6,12 +6,15 @@ OpenRouter pipeline. Run via ./local_start.sh.
from __future__ import annotations
+import json
from pathlib import Path
-from typing import Any, Callable
+from typing import Any, Callable, Iterator
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 llm import LLMError, LlmClient
from prompts import (
@@ -246,6 +249,57 @@ def _translate_sentences(req: TranslateRequest) -> TranslateResponse:
return TranslateResponse(paragraph_id=req.paragraph_id, sentences=pairs)
+class ChatMessage(BaseModel):
+ """对话页的一轮输入。
+
+ 业务上下文(原文/改写稿/顾问要求)每轮重发:顾问可能刚在工作台改完一段,
+ 服务端不能假设自己手上那份还是最新的。session 里保留的是分析/诊断结果。"""
+
+ session_id: str | None = None
+ message: str = ""
+ prompt: str = ""
+ word_limit: int | None = None
+ paragraphs: list[str] = []
+ constraints: list[str] = []
+ rewrite_paragraphs: list[str] = []
+ confirmed_anchors: list[str] = []
+ paragraph_constraints: dict[str, list[str]] = {}
+ # 只有新会话的第一轮会带:页面刷新/进程重启后服务端手里没有已完成的结果,
+ # 缺了它们复检会静默降级(少 Pattern 对照、锚点为空)。老会话重发是纯浪费。
+ analysis: dict | None = None
+ diagnosis: dict | None = None
+
+
+def _sse(name: str, data: dict[str, Any]) -> str:
+ return f"event: {name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+
+
+@app.post("/api/chat/stream")
+def chat_stream(req: ChatMessage) -> StreamingResponse:
+ """对话页唯一入口(SSE)。事件契约见 docs/Agent化改造方案_20260911.md §四。
+
+ 用同步生成器:StreamingResponse 会把它丢进线程池,与本文件其余同步端点一致。
+ 事件里的异常一律翻成 error 帧而不是 500 —— 流已经开了,HTTP 状态码改不了,
+ 前端只能靠 error 帧知道失败。"""
+ sess = agent.get_session(req.session_id, req.model_dump())
+
+ def gen() -> Iterator[str]:
+ try:
+ for ev in agent.run_agent_stream(sess, req.message, get_client()):
+ yield _sse(ev["event"], ev["data"])
+ except LLMError as exc:
+ yield _sse("error", {"code": "llm_error", "msg": str(exc)})
+ except Exception as exc: # 兜底:不让未预期异常变成半截流
+ yield _sse("error", {"code": "internal", "msg": f"{exc.__class__.__name__}:{exc}"})
+
+ return StreamingResponse(
+ gen(),
+ media_type="text/event-stream",
+ # no-store + X-Accel-Buffering:中间层缓冲会把流攒成一坨再吐,流式就白做了
+ headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
+ )
+
+
@app.get("/")
def index() -> FileResponse:
return FileResponse(HERE / "index.html", media_type="text/html")
diff --git a/prompts.py b/prompts.py
index 9ac44cd..2fca716 100644
--- a/prompts.py
+++ b/prompts.py
@@ -115,7 +115,7 @@ def build_diagnose_prompt(
要求:
- 输出 Generative Writing Patterns,不输出 AI 概率 / AI Score / 是否 AI 写的判断。
-- 只输出当前文章实际命中的 Pattern;没有确凿原文证据不得硬凑。全篇最多 5 个 Pattern(只保留最影响本人感的),单段最多 2 个主要 Pattern。
+- 只输出当前文章实际命中的 Pattern;没有确凿原文证据不得硬凑。全篇 5–10 个 Pattern,单段 1–3 个主要 Pattern。证据优先于数量——原文里确实找不到确凿 evidence 的问题类型就不写它;数量下限永远不得靠编造 evidence 来凑(PRD §13.1:模型不得无 evidence 输出 Pattern)。
- 输出必须紧凑(这是给顾问的人工工作流,不是写报告):overall_diagnosis ≤ 3 句;每个 Pattern 的文本字段(why_ai_like / human_impact / transformation_rule)各 ≤ 1 句、evidence 每项 ≤ 12 个词;annotations 每段最多 3 条,每条 observation / rewrite_action / why_ai_like 各 ≤ 1 句;paragraph_briefs 的 confirmed_meaning / rewrite_goal / ai_focus / context_hint 各 ≤ 2 句;must_preserve ≤ 3 项;scaffold 只给填空 / 思考顺序 / 一句不完整起笔。
- evidence 必须是该段原文中的原句或原短语,逐字引用;禁止改写原文当作证据;禁止输出原文不存在的片段。
- 每个 Pattern 都要说明为什么生成模型容易这么写(why_ai_like)与为什么影响本人感(human_impact)。
diff --git a/skills/hvr-rewrite/SKILL.md b/skills/hvr-rewrite/SKILL.md
new file mode 100644
index 0000000..eb2b0d1
--- /dev/null
+++ b/skills/hvr-rewrite/SKILL.md
@@ -0,0 +1,321 @@
+---
+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 Prompt: ...
+Word Limit: ...
+段落原文:
+...
+...
+改写稿(若顾问已在工作台改写):
+...
+
+```
+
+- **段落 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. **建议的下一步**——给 2–3 个选项让顾问选(用 `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` 返回的 **P01–P12** 是产品化的结构化 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–10 个 Pattern、单段 1–3 个主 Pattern、
+每段最多 3 条批注)。这是聚焦,不是穷举——顾问是在工作台上人工改,不是读报告。
+
+但顾问追问「这段还有别的问题吗」时,**你可以基于上下文原文继续补充观察**:
+
+- 补充的每一条同样必须逐字引用原文证据,指不回去的不说
+- 明确区分「工具已标记的主问题」和「我另外看到的次要问题」
+- 补充观察不改变主问题的优先级——主问题仍然是先改的
+
+**禁止**:为了显得全面而凑数。全篇 5–10 是目标区间,不是 quota——某类问题在原文里确实找不到确凿证据,
+就不写它,更不许编造 evidence 去够数。宁可少报,不许假报。
+
+---
+
+# 诚实性硬约束
+
+这几条是红线,破一次顾问就不会再信你:
+
+1. **没调工具,不许说「已分析」「诊断完成」「我看过了」**。你可以说「我先把原文读一遍」,
+ 但给结论前必须有工具结果。
+2. **不许编造原文**。引用必须逐字来自上下文或工具返回,一个词都不能自己造。
+3. **不许编造学生素材**。「如果这里加一个具体的实验室细节会更好」可以;
+ 「如果这里写你三年级转学那次」不行——你不知道学生经历过什么。
+4. **工具失败要明说**。工具返回 `ok: false` 时,直接告诉顾问失败了、失败原因是什么,
+ 不要用你自己的想法把结果补齐。
+5. **不确定就说不确定**。语义锚点、改写方向这类判断,拿不准时标出来让顾问确认,不要替顾问拍板。
+
+---
+
+# 输出风格
+
+- **短**。顾问在手机或平板上看,一段不超过 3 行。
+- **具体**。不写「更自然」「更具体」「更有感染力」这类空话——说清楚改哪个词、换成什么方向。
+- **引用原文**用引号包住英文原句,段号用 `p2` 这样的 id。
+- 列举时用短横线,不要用嵌套三层的大纲。
+- **一次只推进一件事**。给完分析后,明确告诉顾问下一步该做什么(或给按钮选)。
+- 不寒暄、不总结「希望这些对您有帮助」、不重复顾问刚说过的话。
+- **工具结果要转述成话**。工具卡片只显示「读懂全文 ✓」这种一行状态,**结论得你写出来**。
+ 调完工具那一轮先写正文再收尾,别让顾问对着几个卡片自己猜内容——这一点对每一轮都成立,
+ 不只是首轮盘点。
+
+---
+
+# 工具调用格式
+
+调用工具时,参数必须是**结构化 JSON**,段落用 `p1`/`p2` 这样的 id,不要传整段原文——
+原文内核已经知道,你只需要告诉工具「对哪一段、做什么」。
+
+`let_user_confirm` 是唯一会**暂停**的工具:调用后你会停下来等顾问点选,
+顾问的选择会作为下一轮消息回到你这里。所以问题要问得能直接回答,选项要覆盖你可能采取的行动。
diff --git a/test_agent.py b/test_agent.py
new file mode 100644
index 0000000..ff08141
--- /dev/null
+++ b/test_agent.py
@@ -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 "" in prompt and "" in prompt
+ assert "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
diff --git a/tools.py b/tools.py
new file mode 100644
index 0000000..283bd5a
--- /dev/null
+++ b/tools.py
@@ -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="")