From 94a26227c11532a229b0ce4aa7d9bb4e41237607 Mon Sep 17 00:00:00 2001 From: LuminousRuoxi <2494735589@qq.com> Date: Fri, 11 Sep 2026 10:25:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=B9=E8=AF=9D=E5=B1=82=20Agent=20?= =?UTF-8?q?=E5=8C=96=EF=BC=88=E5=AF=B9=E8=AF=9D=E5=BA=95=E5=BA=A7=20+=20?= =?UTF-8?q?=E8=87=AA=E7=A0=94=20skill=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把对话页从固定状态机换成 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 + 真实字节回放」这一层。 --- agent.py | 308 ++++++++++++++++++++ index.html | 331 ++++++++++++++++----- llm.py | 153 ++++++++++ main.py | 58 +++- skills/hvr-rewrite/SKILL.md | 320 ++++++++++++++++++++ test_agent.py | 510 ++++++++++++++++++++++++++++++++ tools.py | 563 ++++++++++++++++++++++++++++++++++++ 7 files changed, 2170 insertions(+), 73 deletions(-) create mode 100644 agent.py create mode 100644 skills/hvr-rewrite/SKILL.md create mode 100644 test_agent.py create mode 100644 tools.py 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..ebc156a 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 会把其它按钮的提示残留下来——用户实测): @@ -826,30 +811,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=[]; } }; + 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 +1556,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/skills/hvr-rewrite/SKILL.md b/skills/hvr-rewrite/SKILL.md new file mode 100644 index 0000000..06e27b7 --- /dev/null +++ b/skills/hvr-rewrite/SKILL.md @@ -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 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 个 Pattern、单段最多 2 个主 Pattern、 +每段最多 3 条批注)。这是刻意的压缩——顾问是在工作台上人工改,不是读报告。 + +但顾问追问「这段还有别的问题吗」时,**你可以基于上下文原文继续补充观察**: + +- 补充的每一条同样必须逐字引用原文证据,指不回去的不说 +- 明确区分「工具已标记的主问题」和「我另外看到的次要问题」 +- 补充观察不改变主问题的优先级——主问题仍然是先改的 + +**禁止**:为了显得全面而凑数。宁可说「这一段主要就是 P05 一个问题」,也不要硬凑到十条。 + +--- + +# 诚实性硬约束 + +这几条是红线,破一次顾问就不会再信你: + +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="")