Compare commits
3
Commits
493c7f6416
...
76d3b30e7d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76d3b30e7d | ||
|
|
bdc6a53f81 | ||
|
|
94a26227c1 |
@@ -0,0 +1,308 @@
|
|||||||
|
"""最小对话底座内核。
|
||||||
|
|
||||||
|
复刻 prodream dreami engine 的三件事(形态与契约,不是那套服务):
|
||||||
|
1. **skill 注入**:SKILL.md 全文拼进 system prompt(固定注入,不做相关性排序)
|
||||||
|
—— 与 engine 的 skill injection 同形,业务逻辑留在 host 这一侧。
|
||||||
|
2. **工具循环**:LLM ⇄ 工具,直到没有 tool_call 为止。engine 跑的就是这个循环。
|
||||||
|
3. **SSE 事件**:产出 transport 无关的事件字典,由 main.py 序列化成 wire 格式。
|
||||||
|
|
||||||
|
明确不做(demo 不需要,做了只是负担):计费、鉴权、附件、MongoDB、
|
||||||
|
caller assertion、向量相关性排序、独立的 engine 进程。
|
||||||
|
|
||||||
|
分层原则(决定了失败模式):
|
||||||
|
- 对话层 = 流式纯文本 + tools,不强制 JSON,不重试(失败即报错,用户看得见)
|
||||||
|
- 工具层 = 非流式 JSON,走 llm.complete_json 的既有重试兜底(那套是验证过的)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
from llm import LLMError, LlmClient
|
||||||
|
from tools import ToolContext, execute, openai_tools
|
||||||
|
|
||||||
|
logger = logging.getLogger("hvr.agent")
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
SKILL_ROOT = HERE / "skills"
|
||||||
|
|
||||||
|
# 一次用户输入最多触发几轮 LLM(每轮可能带一次工具调用)。
|
||||||
|
# 4 足够走完「判断 → 调工具 → 汇报」;给到 6 是为了容忍模型多调一次工具。
|
||||||
|
MAX_TURNS = 6
|
||||||
|
# 会话历史上限(条)。工具结果进历史的是紧凑摘要,但仍要有上限兜底。
|
||||||
|
MAX_HISTORY = 40
|
||||||
|
|
||||||
|
# 新会话没有用户输入时的开场(SKILL.md 规定首轮由 agent 主动盘点)
|
||||||
|
KICKOFF = "请开始首轮盘点。"
|
||||||
|
|
||||||
|
_FRONTMATTER = re.compile(r"^---\n.*?\n---\n", re.DOTALL)
|
||||||
|
|
||||||
|
_SKILL_CACHE: dict[str, str] = {}
|
||||||
|
_SESSIONS: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# skill 加载
|
||||||
|
def load_skill(name: str) -> str:
|
||||||
|
"""读 SKILL.md 并剥掉 frontmatter —— 拼进 system prompt 的是正文。
|
||||||
|
|
||||||
|
frontmatter 是给注册链路读的元数据(engine 用 name/description/tags 做场景
|
||||||
|
命中);这里按名直取,没有命中环节,所以元数据对模型是纯噪声。"""
|
||||||
|
if name in _SKILL_CACHE:
|
||||||
|
return _SKILL_CACHE[name]
|
||||||
|
path = SKILL_ROOT / name / "SKILL.md"
|
||||||
|
if not path.is_file():
|
||||||
|
raise LLMError(f"找不到 skill:{name}({path})")
|
||||||
|
text = _FRONTMATTER.sub("", path.read_text(encoding="utf-8")).strip()
|
||||||
|
if not text:
|
||||||
|
raise LLMError(f"skill 内容为空:{name}")
|
||||||
|
# 缓存是刻意的:SKILL.md 是静态资产,会话期间不会变;改完 skill 需重启进程
|
||||||
|
_SKILL_CACHE[name] = text
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _context_block(ctx: ToolContext) -> str:
|
||||||
|
"""业务上下文块。原文只在这里注入一次,工具入参不重抄 —— 这是省 token
|
||||||
|
与防篡改(模型重抄原文可能改写原文)的关键。"""
|
||||||
|
lines: list[str] = ["<essay_context>"]
|
||||||
|
lines.append(f"Essay Prompt: {ctx.prompt or '(未提供)'}")
|
||||||
|
lines.append(f"Word Limit: {ctx.word_limit or '(未提供)'}")
|
||||||
|
lines.append("段落原文:")
|
||||||
|
for i, text in enumerate(ctx.paragraphs, start=1):
|
||||||
|
lines.append(f"<p{i}>{text}</p{i}>")
|
||||||
|
if ctx.rewrite_paragraphs:
|
||||||
|
lines.append("顾问在工作台的当前改写稿:")
|
||||||
|
for i, text in enumerate(ctx.rewrite_paragraphs, start=1):
|
||||||
|
lines.append(f"<p{i}>{text}</p{i}>")
|
||||||
|
else:
|
||||||
|
lines.append("顾问在工作台的当前改写稿:(尚未开始改写)")
|
||||||
|
if ctx.constraints:
|
||||||
|
lines.append("顾问全局要求:" + ";".join(ctx.constraints))
|
||||||
|
lines.append("</essay_context>")
|
||||||
|
|
||||||
|
done: list[str] = []
|
||||||
|
if ctx.analysis:
|
||||||
|
done.append("已完成理解(analyze_essay)")
|
||||||
|
if ctx.diagnosis:
|
||||||
|
done.append("已完成诊断(diagnose_essay)")
|
||||||
|
lines.append("已完成步骤:" + (";".join(done) if done else "无"))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt(ctx: ToolContext, skill_name: str = "hvr-rewrite") -> str:
|
||||||
|
return f"{load_skill(skill_name)}\n\n---\n\n# 当前业务上下文\n\n{_context_block(ctx)}"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 会话
|
||||||
|
def _new_session(req: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
ctx = ToolContext(
|
||||||
|
prompt=str(req.get("prompt") or ""),
|
||||||
|
word_limit=req.get("word_limit") or None,
|
||||||
|
paragraphs=[str(p) for p in (req.get("paragraphs") or [])],
|
||||||
|
constraints=[str(c) for c in (req.get("constraints") or [])],
|
||||||
|
rewrite_paragraphs=[str(p) for p in (req.get("rewrite_paragraphs") or [])],
|
||||||
|
confirmed_anchors=[str(a) for a in (req.get("confirmed_anchors") or [])],
|
||||||
|
paragraph_constraints={
|
||||||
|
str(k): [str(x) for x in (v or [])]
|
||||||
|
for k, v in (req.get("paragraph_constraints") or {}).items()
|
||||||
|
},
|
||||||
|
# 前端在会话第一轮把已完成的分析/诊断带过来(页面刷新、进程重启后新开会话时
|
||||||
|
# 服务端手里没有)。丢了 diagnosis 会让复检的「原 Pattern 是否缓解」失去对照,
|
||||||
|
# 丢了 analysis 会让语义锚点变空 —— 都是静默降级,所以这里必须收。
|
||||||
|
analysis=req.get("analysis") or None,
|
||||||
|
diagnosis=req.get("diagnosis") or None,
|
||||||
|
)
|
||||||
|
return {"sid": uuid.uuid4().hex[:16], "ctx": ctx, "messages": []}
|
||||||
|
|
||||||
|
|
||||||
|
def get_session(session_id: str | None, req: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""续接已有会话;没给 id 或 id 不认识就开新会话。
|
||||||
|
|
||||||
|
id 不认识时开新会话而不是报错:进程重启会清空 _SESSIONS,前端手里的旧 id
|
||||||
|
不该让用户卡死。代价是丢历史,但原文由前端每轮重发,业务上下文不丢。"""
|
||||||
|
if session_id and session_id in _SESSIONS:
|
||||||
|
sess = _SESSIONS[session_id]
|
||||||
|
_refresh_ctx(sess["ctx"], req)
|
||||||
|
return sess
|
||||||
|
sess = _new_session(req)
|
||||||
|
_SESSIONS[sess["sid"]] = sess
|
||||||
|
# 会话数上限:demo 场景不会有几百个会话,超了丢最早的防内存无限增长
|
||||||
|
while len(_SESSIONS) > 200:
|
||||||
|
_SESSIONS.pop(next(iter(_SESSIONS)))
|
||||||
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_ctx(ctx: ToolContext, req: dict[str, Any]) -> None:
|
||||||
|
"""前端每轮重发业务上下文 —— 顾问可能刚在工作台改完稿、刚补了要求。
|
||||||
|
已完成的分析/诊断结果保留,不因重发而丢。"""
|
||||||
|
if req.get("paragraphs"):
|
||||||
|
ctx.paragraphs = [str(p) for p in req["paragraphs"]]
|
||||||
|
if "rewrite_paragraphs" in req:
|
||||||
|
ctx.rewrite_paragraphs = [str(p) for p in (req.get("rewrite_paragraphs") or [])]
|
||||||
|
if "constraints" in req:
|
||||||
|
ctx.constraints = [str(c) for c in (req.get("constraints") or [])]
|
||||||
|
if "confirmed_anchors" in req:
|
||||||
|
ctx.confirmed_anchors = [str(a) for a in (req.get("confirmed_anchors") or [])]
|
||||||
|
if "paragraph_constraints" in req:
|
||||||
|
ctx.paragraph_constraints = {
|
||||||
|
str(k): [str(x) for x in (v or [])]
|
||||||
|
for k, v in (req.get("paragraph_constraints") or {}).items()
|
||||||
|
}
|
||||||
|
if "prompt" in req:
|
||||||
|
ctx.prompt = str(req.get("prompt") or "")
|
||||||
|
if "word_limit" in req:
|
||||||
|
ctx.word_limit = req.get("word_limit") or None
|
||||||
|
|
||||||
|
|
||||||
|
def drop_session(session_id: str) -> bool:
|
||||||
|
return _SESSIONS.pop(session_id, None) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _trim(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""保留最近 MAX_HISTORY 条,但**不能从 tool 消息开头切**——OpenAI 协议要求
|
||||||
|
tool 消息必须紧跟带 tool_calls 的 assistant 消息,悬空的 tool 消息会被拒。"""
|
||||||
|
if len(messages) <= MAX_HISTORY:
|
||||||
|
return messages
|
||||||
|
cut = len(messages) - MAX_HISTORY
|
||||||
|
while cut < len(messages) and messages[cut].get("role") == "tool":
|
||||||
|
cut += 1
|
||||||
|
return messages[cut:]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 事件
|
||||||
|
def _ev(name: str, **data: Any) -> dict[str, Any]:
|
||||||
|
return {"event": name, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 工具循环
|
||||||
|
def run_agent_stream(
|
||||||
|
sess: dict[str, Any],
|
||||||
|
user_message: str,
|
||||||
|
client: LlmClient,
|
||||||
|
skill_name: str = "hvr-rewrite",
|
||||||
|
) -> Iterator[dict[str, Any]]:
|
||||||
|
"""跑一轮对话,逐条 yield 事件字典。
|
||||||
|
|
||||||
|
事件契约(照搬 prodream,见 docs/Agent化改造方案_20260911.md §四):
|
||||||
|
meta / session_id / token / thinking / tool_call / tool_result /
|
||||||
|
tool_request_clarify / done / error
|
||||||
|
"""
|
||||||
|
ctx: ToolContext = sess["ctx"]
|
||||||
|
history: list[dict[str, Any]] = sess["messages"]
|
||||||
|
trace_id = uuid.uuid4().hex[:16]
|
||||||
|
|
||||||
|
yield _ev("meta", trace_id=trace_id)
|
||||||
|
yield _ev("session_id", session_id=sess["sid"])
|
||||||
|
|
||||||
|
if not ctx.paragraphs:
|
||||||
|
yield _ev("error", code="no_essay", msg="还没有拿到文书原文,请先在左侧粘贴并切分段落")
|
||||||
|
return
|
||||||
|
|
||||||
|
history.append({"role": "user", "content": user_message or KICKOFF})
|
||||||
|
messages = [{"role": "system", "content": build_system_prompt(ctx, skill_name)}, *_trim(history)]
|
||||||
|
|
||||||
|
try:
|
||||||
|
for turn in range(MAX_TURNS):
|
||||||
|
text_parts: list[str] = []
|
||||||
|
tool_calls: list[dict[str, str]] = []
|
||||||
|
finish = ""
|
||||||
|
for ev in client.stream_chat(messages, tools=openai_tools()):
|
||||||
|
kind = ev.get("type")
|
||||||
|
if kind == "text":
|
||||||
|
text_parts.append(ev["text"])
|
||||||
|
yield _ev("token", text=ev["text"])
|
||||||
|
elif kind == "thinking":
|
||||||
|
yield _ev("thinking", text=ev["text"])
|
||||||
|
elif kind == "tool_calls":
|
||||||
|
tool_calls = ev["tool_calls"]
|
||||||
|
elif kind == "done":
|
||||||
|
finish = ev.get("finish_reason") or "stop"
|
||||||
|
|
||||||
|
if not tool_calls:
|
||||||
|
history.append({"role": "assistant", "content": "".join(text_parts)})
|
||||||
|
yield _ev("done", finish_reason=finish or "stop")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 工具调用轮:正文通常是空的,仍要落历史(部分模型会给一句过渡语)
|
||||||
|
history.append(
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "".join(text_parts) or None,
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": c["id"],
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": c["name"], "arguments": c["arguments"] or "{}"},
|
||||||
|
}
|
||||||
|
for c in tool_calls
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
messages.append(history[-1])
|
||||||
|
|
||||||
|
for call in tool_calls:
|
||||||
|
name = call.get("name") or ""
|
||||||
|
try:
|
||||||
|
args = json.loads(call.get("arguments") or "{}")
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
args = {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
args = {}
|
||||||
|
logger.warning("tool args 不是合法 JSON:%s", (call.get("arguments") or "")[:200])
|
||||||
|
|
||||||
|
yield _ev("tool_call", tool=name, tool_call_id=call["id"], args=args)
|
||||||
|
|
||||||
|
result = execute(name, args, ctx)
|
||||||
|
if not result.ok:
|
||||||
|
logger.warning("tool failed name=%s error=%s", name, result.error)
|
||||||
|
|
||||||
|
yield _ev(
|
||||||
|
"tool_result",
|
||||||
|
tool=name,
|
||||||
|
tool_call_id=call["id"],
|
||||||
|
ok=result.ok,
|
||||||
|
result_summary=result.summary or result.error,
|
||||||
|
payload=result.payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 进历史的是摘要(或错误原文)——诊断 JSON 有 2.5k tokens,
|
||||||
|
# 原样回灌会让每轮成本滚雪球
|
||||||
|
tool_msg = {
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": call["id"],
|
||||||
|
"content": json.dumps(
|
||||||
|
{"ok": result.ok, "result": result.summary or result.error},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
history.append(tool_msg)
|
||||||
|
messages.append(tool_msg)
|
||||||
|
|
||||||
|
if result.stop:
|
||||||
|
# 交互型工具:本轮到此为止,等顾问点选后由下一条消息继续
|
||||||
|
logger.warning("agent await user name=%s turn=%s", name, turn)
|
||||||
|
yield _ev(
|
||||||
|
"tool_request_clarify",
|
||||||
|
clarify_id=call["id"],
|
||||||
|
question=(result.clarify or {}).get("question", ""),
|
||||||
|
choices=(result.clarify or {}).get("choices", []),
|
||||||
|
)
|
||||||
|
yield _ev("done", finish_reason="await_user")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 轮次用尽:不是错误,但要如实告诉前端为什么停
|
||||||
|
logger.warning("agent hit MAX_TURNS=%s", MAX_TURNS)
|
||||||
|
yield _ev("error", code="max_turns", msg=f"本次操作步骤过多(超过 {MAX_TURNS} 轮),请把要求拆开再问")
|
||||||
|
except LLMError as exc:
|
||||||
|
logger.warning("agent llm error: %s", exc)
|
||||||
|
yield _ev("error", code="llm_error", msg=str(exc))
|
||||||
|
finally:
|
||||||
|
sess["messages"] = _trim(history)
|
||||||
+265
-83
@@ -46,6 +46,20 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
|
|||||||
.bubble h1{font-size:25px;margin:0 0 7px;line-height:1.35}.bubble h2{font-size:18px;margin:20px 0 10px}.bubble h3{font-size:14px;margin:15px 0 7px}.bubble p{margin:7px 0;color:#414757}.user .bubble p{color:#fff;margin:0}.eyebrow{color:var(--brand);font-size:12px;font-weight:800;margin-bottom:7px}.bubble .muted{color:var(--muted);font-size:13px}.summary{background:#f8f8ff;border:1px solid #dfe1ff;border-radius:12px;padding:14px 16px;color:#30364a;line-height:1.75;margin:12px 0}
|
.bubble h1{font-size:25px;margin:0 0 7px;line-height:1.35}.bubble h2{font-size:18px;margin:20px 0 10px}.bubble h3{font-size:14px;margin:15px 0 7px}.bubble p{margin:7px 0;color:#414757}.user .bubble p{color:#fff;margin:0}.eyebrow{color:var(--brand);font-size:12px;font-weight:800;margin-bottom:7px}.bubble .muted{color:var(--muted);font-size:13px}.summary{background:#f8f8ff;border:1px solid #dfe1ff;border-radius:12px;padding:14px 16px;color:#30364a;line-height:1.75;margin:12px 0}
|
||||||
.agent-table{width:100%;border-collapse:separate;border-spacing:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;margin:10px 0 14px;font-size:13px}.agent-table th{background:#f8f9fc;text-align:left;padding:10px 11px;color:#687084;font-weight:700;border-bottom:1px solid var(--line)}.agent-table td{padding:11px;border-bottom:1px solid var(--line);vertical-align:top}.agent-table tr:last-child td{border-bottom:0}.agent-table td:first-child{font-weight:700;white-space:nowrap}
|
.agent-table{width:100%;border-collapse:separate;border-spacing:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;margin:10px 0 14px;font-size:13px}.agent-table th{background:#f8f9fc;text-align:left;padding:10px 11px;color:#687084;font-weight:700;border-bottom:1px solid var(--line)}.agent-table td{padding:11px;border-bottom:1px solid var(--line);vertical-align:top}.agent-table tr:last-child td{border-bottom:0}.agent-table td:first-child{font-weight:700;white-space:nowrap}
|
||||||
.inline-note{background:#fafbff;border-left:3px solid #7775f4;padding:9px 12px;border-radius:5px;margin:10px 0;color:#555c70;font-size:13px}.quick-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.quick{border:1px solid #dfe1ec;background:#fff;border-radius:999px;padding:8px 12px;font-size:12px;color:#4f5668;font-weight:650}.quick:hover{border-color:#b8b9ff;color:#4440d5;background:#f8f8ff}.quick.primary{background:var(--brand);border-color:var(--brand);color:#fff;padding:10px 18px;box-shadow:0 6px 16px rgba(86,84,245,.18)}.quick.primary:hover{background:var(--brand2);color:#fff}.quick.ghost{background:#fff}.quick:disabled{opacity:.45;cursor:not-allowed}
|
.inline-note{background:#fafbff;border-left:3px solid #7775f4;padding:9px 12px;border-radius:5px;margin:10px 0;color:#555c70;font-size:13px}.quick-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.quick{border:1px solid #dfe1ec;background:#fff;border-radius:999px;padding:8px 12px;font-size:12px;color:#4f5668;font-weight:650}.quick:hover{border-color:#b8b9ff;color:#4440d5;background:#f8f8ff}.quick.primary{background:var(--brand);border-color:var(--brand);color:#fff;padding:10px 18px;box-shadow:0 6px 16px rgba(86,84,245,.18)}.quick.primary:hover{background:var(--brand2);color:#fff}.quick.ghost{background:#fff}.quick:disabled{opacity:.45;cursor:not-allowed}
|
||||||
|
/* Agent 对话(SSE 流式):正文逐字出 + 工具卡片 + 选项按钮 */
|
||||||
|
.stream-body p{margin:0 0 10px}.stream-body p:last-child{margin-bottom:0}
|
||||||
|
.stream-body ul.agent-list{margin:0 0 10px;padding-left:18px}.stream-body ul.agent-list li{margin-bottom:4px}
|
||||||
|
.stream-error{color:#c0392b;font-size:13px;margin:8px 0 0}
|
||||||
|
.tool-cards{display:flex;flex-direction:column;gap:6px;margin-top:10px}
|
||||||
|
.tool-card{display:flex;align-items:center;gap:8px;background:#f6f7fc;border:1px solid #e6e8f4;border-radius:8px;padding:7px 11px;font-size:12px;color:#555c70}
|
||||||
|
.tool-card .tool-name{font-weight:650;color:#4f5668;white-space:nowrap;flex:none}
|
||||||
|
.tool-card .tool-sum{color:#7a8095;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.tool-card.running{border-style:dashed}
|
||||||
|
.tool-card.failed{background:#fff6f6;border-color:#f2d5d5}
|
||||||
|
.tool-card.failed .tool-name{color:#a94442}.tool-card.failed .tool-sum{color:#c0392b;white-space:normal}
|
||||||
|
.tool-dot{width:7px;height:7px;border-radius:50%;background:#3bb273;flex:none}
|
||||||
|
.tool-card.failed .tool-dot{background:#e05c5c}
|
||||||
|
.choice-q{margin:0;font-size:13px;color:#4f5668;font-weight:650;flex:0 0 100%}
|
||||||
.agent-input{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(180deg,rgba(246,247,251,0),#f6f7fb 26%);padding:30px 20px 18px;z-index:40}.composer{max-width:900px;margin:0 auto;background:#fff;border:1px solid #dcdfe8;border-radius:15px;padding:9px 10px 9px 14px;display:flex;gap:10px;align-items:flex-end;box-shadow:0 10px 28px rgba(28,31,45,.09)}.composer textarea{flex:1;border:0;outline:0;resize:none;min-height:38px;max-height:120px;padding:7px 4px;font-size:13px}.composer button{width:36px;height:36px;border-radius:10px;border:0;background:var(--brand);color:#fff;font-weight:800}.input-hint{max-width:900px;margin:8px auto 0;color:#d83a3a;font-size:12px;font-weight:600;padding-left:14px}
|
.agent-input{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(180deg,rgba(246,247,251,0),#f6f7fb 26%);padding:30px 20px 18px;z-index:40}.composer{max-width:900px;margin:0 auto;background:#fff;border:1px solid #dcdfe8;border-radius:15px;padding:9px 10px 9px 14px;display:flex;gap:10px;align-items:flex-end;box-shadow:0 10px 28px rgba(28,31,45,.09)}.composer textarea{flex:1;border:0;outline:0;resize:none;min-height:38px;max-height:120px;padding:7px 4px;font-size:13px}.composer button{width:36px;height:36px;border-radius:10px;border:0;background:var(--brand);color:#fff;font-weight:800}.input-hint{max-width:900px;margin:8px auto 0;color:#d83a3a;font-size:12px;font-weight:600;padding-left:14px}
|
||||||
.status-line{display:flex;gap:8px;align-items:center;margin:7px 0 13px;color:#737a8b;font-size:12px}.status-line .pill{padding:4px 8px;border-radius:999px;background:#f1f2f6}.constraint-box{margin-top:13px;background:#f9faff;border:1px solid #e0e2ef;border-radius:11px;padding:11px 13px;font-size:13px;color:#52596c}
|
.status-line{display:flex;gap:8px;align-items:center;margin:7px 0 13px;color:#737a8b;font-size:12px}.status-line .pill{padding:4px 8px;border-radius:999px;background:#f1f2f6}.constraint-box{margin-top:13px;background:#f9faff;border:1px solid #e0e2ef;border-radius:11px;padding:11px 13px;font-size:13px;color:#52596c}
|
||||||
|
|
||||||
@@ -505,7 +519,7 @@ function persistNow(){
|
|||||||
referencesShown, scaffoldsShown, currentRewriteVersion,
|
referencesShown, scaffoldsShown, currentRewriteVersion,
|
||||||
lastRecheckSnapshot, lastRecheckResult, analysisVersion, agentStage,
|
lastRecheckSnapshot, lastRecheckResult, analysisVersion, agentStage,
|
||||||
chatLog, essayVersions, viewedVersion, recheckHistory, translations, translateOpen,
|
chatLog, essayVersions, viewedVersion, recheckHistory, translations, translateOpen,
|
||||||
origTranslations, origTranslateOpen
|
origTranslations, origTranslateOpen, chatSessionId
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
}catch(e){ return false; }
|
}catch(e){ return false; }
|
||||||
@@ -559,6 +573,9 @@ function restoreSession(){
|
|||||||
lastRecheckResult = s.lastRecheckResult || null;
|
lastRecheckResult = s.lastRecheckResult || null;
|
||||||
analysisVersion = s.analysisVersion || 1;
|
analysisVersion = s.analysisVersion || 1;
|
||||||
chatLog = Array.isArray(s.chatLog) ? s.chatLog : [];
|
chatLog = Array.isArray(s.chatLog) ? s.chatLog : [];
|
||||||
|
// 续接服务端会话:不恢复的话刷新后同一条历史里发出的消息会落到新开的空会话,
|
||||||
|
// agent 会忘记刚才聊过什么(服务端进程重启同样会落到这条兜底路径,行为一致)
|
||||||
|
chatSessionId = typeof s.chatSessionId === 'string' ? s.chatSessionId : null;
|
||||||
chatLogSeq = chatLog.length ? Math.max(...chatLog.map(r=>r.id||0)) : 0;
|
chatLogSeq = chatLog.length ? Math.max(...chatLog.map(r=>r.id||0)) : 0;
|
||||||
essayVersions = Array.isArray(s.essayVersions) ? s.essayVersions : [];
|
essayVersions = Array.isArray(s.essayVersions) ? s.essayVersions : [];
|
||||||
viewedVersion = essayVersions.some(v=>v.version===s.viewedVersion) ? s.viewedVersion : null;
|
viewedVersion = essayVersions.some(v=>v.version===s.viewedVersion) ? s.viewedVersion : null;
|
||||||
@@ -646,29 +663,12 @@ async function runAnalyze(){
|
|||||||
viewedVersion = null; // 新一轮以原版为工作底稿(已完成的版本 N 仍保留可切换)
|
viewedVersion = null; // 新一轮以原版为工作底稿(已完成的版本 N 仍保留可切换)
|
||||||
ensureParaState();
|
ensureParaState();
|
||||||
track('human_voice_analysis_start', {paragraphs: paragraphs.length});
|
track('human_voice_analysis_start', {paragraphs: paragraphs.length});
|
||||||
show('agentView'); chat.innerHTML=''; chatLog=[]; agentStage='loading';
|
show('agentView'); chat.innerHTML=''; chatLog=[]; chatSessionId=null;
|
||||||
// 统一走 addLoadingCard:带 45s/120s 超时提示(真实模型生成可能超过 1 分钟)
|
agentStage='loading';
|
||||||
const loading = addLoadingCard('正在读懂这篇文书…', ['正在结合题目理解全文','正在确认每一段实际表达的内容','正在整理需要与你确认的理解']);
|
// 首轮不再直接打 /api/analyze:交给 agent 按 SKILL.md「首轮盘点」自己调 analyze_essay,
|
||||||
const rows = loading.querySelectorAll('.loading-row');
|
// 盘点结论 + 理解卡一起给出,并由它用 let_user_confirm 问顾问下一步。
|
||||||
const t1 = setTimeout(()=>{rows[0].classList.remove('active');rows[1].classList.add('active')},650);
|
// 改造前这里是一条固定流水线(分析 → 等确认 → 诊断),现在由 skill 驱动。
|
||||||
const t2 = setTimeout(()=>{rows[1].classList.remove('active');rows[2].classList.add('active')},1300);
|
streamAgent('请开始首轮盘点。');
|
||||||
try{
|
|
||||||
const [resp] = await Promise.all([
|
|
||||||
postJSON('/api/analyze',{prompt:promptText, word_limit:wordLimit, paragraphs, constraints:[]}),
|
|
||||||
sleep(900)
|
|
||||||
]);
|
|
||||||
clearTimeout(t1);clearTimeout(t2);
|
|
||||||
removeEl(loading);
|
|
||||||
analysis = resp;
|
|
||||||
agentStage='understanding';
|
|
||||||
persistNow();
|
|
||||||
renderUnderstanding();
|
|
||||||
}catch(e){
|
|
||||||
clearTimeout(t1);clearTimeout(t2);
|
|
||||||
removeEl(loading);
|
|
||||||
agentStage='idle';
|
|
||||||
renderError(e.message, runAnalyze);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestDiagnosis(){
|
async function requestDiagnosis(){
|
||||||
@@ -681,29 +681,10 @@ async function requestDiagnosis(){
|
|||||||
});
|
});
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
async function runDiagnose(opts){
|
// 原先这里有一条对话页专用的 runDiagnose(带 loading 卡、跑 /api/diagnose 再渲染诊断卡)。
|
||||||
// 防重入:诊断请求进行中忽略重复触发(用户实测连点【理解准确,继续分析】——
|
// Agent 化之后诊断由 agent 调 diagnose_essay 工具触发,载荷经 applyToolPayload 走同一条
|
||||||
// 修复前两次并发请求会各渲染一张诊断卡并白花一次模型调用,见 Deviations #23)
|
// renderDiagnosis,这个函数已无调用点,删掉避免两套并行的诊断路径各自漂移。
|
||||||
if(agentStage==='diagloading') return;
|
// 工作台的 submitConstraint 仍直接调 requestDiagnosis —— 那是工作台自己的路径,未改动。
|
||||||
const regen = !!(opts && opts.regen);
|
|
||||||
agentStage = 'diagloading';
|
|
||||||
const loading = regen
|
|
||||||
? addLoadingCard('正在根据你的补充重新诊断…', ['正在合并你新确认的要求','正在重新生成各段批注与改写方向'])
|
|
||||||
: addLoadingCard('正在分析明显的 AI 写作习惯…', ['正在对照已确认的语义锚点','正在识别实际存在的生成式写作模式','正在整理各段改写方向']);
|
|
||||||
try{
|
|
||||||
const resp = await requestDiagnosis();
|
|
||||||
removeEl(loading);
|
|
||||||
diagnosis = resp;
|
|
||||||
agentStage='diagnosis';
|
|
||||||
persistNow();
|
|
||||||
renderDiagnosis();
|
|
||||||
}catch(e){
|
|
||||||
removeEl(loading);
|
|
||||||
// 重诊断失败时约束已记入,保留 diagnosis 状态可再次重试
|
|
||||||
agentStage = regen ? 'diagnosis' : 'understanding';
|
|
||||||
renderError(e.message, ()=>runDiagnose(opts));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDiagnosis(){
|
function renderDiagnosis(){
|
||||||
const d = diagnosis;
|
const d = diagnosis;
|
||||||
@@ -754,10 +735,14 @@ function handleAction(action){
|
|||||||
// 对话页与 Workbench 都能显示。
|
// 对话页与 Workbench 都能显示。
|
||||||
if(action==='view-recheck-history'){ renderRecheckHistory(); toggleRecheckHistory(); return; }
|
if(action==='view-recheck-history'){ renderRecheckHistory(); toggleRecheckHistory(); return; }
|
||||||
if(action==='confirm-understanding'){
|
if(action==='confirm-understanding'){
|
||||||
// 防重入:诊断请求进行中忽略连点(用户实测连点——修复前两次并发请求 +
|
// 防重入:这一轮 agent 还没跑完就忽略连点(原先防的是两次并发 /api/diagnose,
|
||||||
// 两个确认气泡 + 两张诊断卡,见 Deviations #23);runDiagnose 内另有同守卫兜底
|
// 连点会各渲染一张诊断卡;现在连点会各起一条 SSE 流,等价问题)
|
||||||
if(agentStage==='diagloading') return;
|
if(agentBusy||agentStage==='diagloading') return;
|
||||||
addUser('理解准确,继续。');track('understanding_confirmed');runDiagnose();
|
track('understanding_confirmed');
|
||||||
|
addUser('理解准确,继续。');
|
||||||
|
// 不再直接调 runDiagnose():由 agent 自己决定下一步并调 diagnose_essay,
|
||||||
|
// 它要能在这条消息里同时处理「确认 + 顺带的补充要求」
|
||||||
|
streamAgent('顾问确认理解准确,请继续。');
|
||||||
}
|
}
|
||||||
if(action==='adjust-understanding'){const input=$('#chatInput');input.placeholder='例如:第三段重点不是接受不确定,而是更关注推理过程……';input.focus();}
|
if(action==='adjust-understanding'){const input=$('#chatInput');input.placeholder='例如:第三段重点不是接受不确定,而是更关注推理过程……';input.focus();}
|
||||||
// 每个按钮给出自己的占位提示再聚焦(只 focus 会把其它按钮的提示残留下来——用户实测):
|
// 每个按钮给出自己的占位提示再聚焦(只 focus 会把其它按钮的提示残留下来——用户实测):
|
||||||
@@ -785,18 +770,11 @@ function handleAction(action){
|
|||||||
renderEntryEssay();
|
renderEntryEssay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function applyUnderstandingCorrection(text){
|
// 原先这里是 applyUnderstandingCorrection:顾问在理解态说「第三段重点不是……」时,
|
||||||
const idx = parseParagraphIndex(text);
|
// 它把这句话同时写进 analysis.paragraphs[i] 的 natural_meaning_zh / semantic_anchor 和
|
||||||
if(idx===null || !analysis || !analysis.paragraphs[idx]){
|
// paragraphConstraints。Agent 化之后这条输入走 record_advisor_note,只落成分段约束,
|
||||||
globalConstraints.push(text);
|
// **锚点不再跟着改**(纠正内容仍会进诊断/复检的 paragraph_constraints)。
|
||||||
return {scope:'global', label:'文章理解的补充'};
|
// 这是已知偏差,不是遗漏:要不要补一个带 anchor 更新的「纠正理解」工具待定。
|
||||||
}
|
|
||||||
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)} 的理解`};
|
|
||||||
}
|
|
||||||
function applyDiagnosisConstraint(text){
|
function applyDiagnosisConstraint(text){
|
||||||
const raw = text.trim();
|
const raw = text.trim();
|
||||||
const idx = parseParagraphIndex(raw);
|
const idx = parseParagraphIndex(raw);
|
||||||
@@ -826,30 +804,232 @@ function showInputHint(){
|
|||||||
clearTimeout(hint._t);
|
clearTimeout(hint._t);
|
||||||
hint._t=setTimeout(()=>hint.classList.add('hidden'),3000);
|
hint._t=setTimeout(()=>hint.classList.add('hidden'),3000);
|
||||||
}
|
}
|
||||||
|
/* ===== Agent 对话(SSE 流式)=====
|
||||||
|
原先是按 agentStage 分支回复的固定状态机(理解态收补充→要求再点确认、诊断态收补充→
|
||||||
|
直接重跑…)。现在用户说什么都原样发给 /api/chat/stream,由 skill 驱动的 agent 自己
|
||||||
|
判断该聊还是该调工具。
|
||||||
|
|
||||||
|
agentStage 保留下来了,但语义变了:它不再是「回复分支的依据」,而是「哪些工具跑过」
|
||||||
|
的结果——工作台入口与按钮置灰仍按它判断(renderDiagnosis 的【进入逐段改写】、
|
||||||
|
disableCompletedActions 的置灰规则都依赖它)。*/
|
||||||
|
let chatSessionId = null;
|
||||||
|
let agentBusy = false;
|
||||||
|
|
||||||
|
const TOOL_LABEL = {
|
||||||
|
analyze_essay:'读懂全文', diagnose_essay:'诊断 AI 味',
|
||||||
|
get_writing_scaffold:'给写作起点', get_reference_snippet:'给参考片段',
|
||||||
|
translate_to_chinese:'中文对照', recheck_rewrite:'全文复检',
|
||||||
|
record_advisor_note:'记下你的要求', let_user_confirm:'等你确认',
|
||||||
|
};
|
||||||
|
// 这三个工具不是流式出字的(走 complete_json 保住校验重试),调用期间只有工具卡片在转,
|
||||||
|
// 长文跑 1 分钟以上很正常——不写清楚顾问会以为卡死了
|
||||||
|
const SLOW_TOOLS = {analyze_essay:'正在读懂全文,长文可能要 1 分钟以上…',
|
||||||
|
diagnose_essay:'正在逐段诊断,可能要 1 分钟以上…',
|
||||||
|
recheck_rewrite:'正在全文复检,可能要 1 分钟以上…'};
|
||||||
|
function toolLabel(name){ return TOOL_LABEL[name] || name; }
|
||||||
|
// 只认模型真正常用的那三种(粗体/行内代码/标题)。输入已经过 escapeHtml,
|
||||||
|
// 这里插进去的标签全是我们自己拼的,模型的 <> 早变成实体了,不会二次注入。
|
||||||
|
function mdInline(s){
|
||||||
|
return s.replace(/\*\*([^*\n]+)\*\*/g,'<strong>$1</strong>').replace(/`([^`\n]+)`/g,'<code>$1</code>');
|
||||||
|
}
|
||||||
|
function renderAgentText(escaped){
|
||||||
|
// 入参已 escapeHtml——流式渲染拼 DOM 时必须先转义再拼,模型输出等同用户输入
|
||||||
|
return String(escaped).split(/\n{2,}/).map(block=>{
|
||||||
|
// 逐行分组而不是「整块要么是列表要么是段落」:模型常写成
|
||||||
|
// 「**2. 最像 AI 写的地方**」这种伪标题 + 紧跟几行短横线,整块判定会把 '-' 漏成字面字符
|
||||||
|
const out = []; let para = []; let items = [];
|
||||||
|
const flushPara = ()=>{ if(para.length){ out.push(`<p>${mdInline(para.join('<br>'))}</p>`); para=[]; } };
|
||||||
|
const flushList = ()=>{ if(items.length){ out.push('<ul class="agent-list">'+items.map(x=>`<li>${mdInline(x)}</li>`).join('')+'</ul>'); items=[]; } };
|
||||||
|
block.split('\n').map(l=>l.trim()).filter(Boolean).forEach(line=>{
|
||||||
|
const item = line.match(/^[-•*]\s+(.*)$/);
|
||||||
|
const head = line.match(/^#{1,6}\s+(.*)$/);
|
||||||
|
if(item){ flushPara(); items.push(item[1]); return; }
|
||||||
|
if(head){ flushList(); flushPara(); out.push(`<h2>${mdInline(head[1])}</h2>`); return; }
|
||||||
|
flushList(); para.push(line);
|
||||||
|
});
|
||||||
|
flushPara(); flushList();
|
||||||
|
return out.join('');
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
// POST 用不了 EventSource,手工解 SSE 帧:`event: X\ndata: {...}\n\n`
|
||||||
|
async function readSSE(resp, onEvent){
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
for(;;){
|
||||||
|
const {done, value} = await reader.read();
|
||||||
|
if(done) break;
|
||||||
|
buf += dec.decode(value, {stream:true}).replace(/\r\n/g,'\n');
|
||||||
|
let i;
|
||||||
|
while((i = buf.indexOf('\n\n')) >= 0){
|
||||||
|
const frame = buf.slice(0,i); buf = buf.slice(i+2);
|
||||||
|
let name = 'message', data = '';
|
||||||
|
frame.split('\n').forEach(line=>{
|
||||||
|
if(line.startsWith('event:')) name = line.slice(6).trim();
|
||||||
|
else if(line.startsWith('data:')) data += line.slice(5).trim();
|
||||||
|
});
|
||||||
|
if(data){ try{ onEvent(name, JSON.parse(data)); }catch(e){} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function agentPayload(message){
|
||||||
|
// 业务上下文每轮重发:顾问可能刚在工作台改完稿。服务端 session 只留分析/诊断结果。
|
||||||
|
const base = {
|
||||||
|
session_id: chatSessionId, message,
|
||||||
|
prompt: promptText, word_limit: wordLimit, paragraphs: paragraphs.slice(),
|
||||||
|
constraints: globalConstraints.slice(),
|
||||||
|
rewrite_paragraphs: paragraphs.map((_,i)=>rewrites[i]||''),
|
||||||
|
confirmed_anchors: ((analysis&&analysis.paragraphs)||[]).map(p=>p.semantic_anchor||p.natural_meaning_zh||''),
|
||||||
|
paragraph_constraints: paragraphConstraints,
|
||||||
|
};
|
||||||
|
// 新会话第一轮才补上分析/诊断:刷新或服务端重启后 ctx 是空的,不带的话
|
||||||
|
// 复检会丢掉语义锚点与 Pattern 对照;拿到 session_id 之后服务端自己留着。
|
||||||
|
if(!chatSessionId){ base.analysis = analysis; base.diagnosis = diagnosis; }
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
function applyToolPayload(payload, pending){
|
||||||
|
// 工具返回的是与旧 endpoint 完全相同的结构,所以这里直接喂给已有的渲染函数——
|
||||||
|
// 复检卡、工作台入口、置灰规则、版本推进全部复用,没有第二套 UI。
|
||||||
|
const data = payload.data || {};
|
||||||
|
const pid = data.paragraph_id || '';
|
||||||
|
if(payload.kind==='analysis'){
|
||||||
|
analysis = data; analysisVersion += 1; agentStage = 'understanding';
|
||||||
|
pending.push(()=>renderUnderstanding());
|
||||||
|
}else if(payload.kind==='diagnosis'){
|
||||||
|
diagnosis = data; agentStage = 'diagnosis';
|
||||||
|
pending.push(()=>renderDiagnosis());
|
||||||
|
}else if(payload.kind==='recheck'){
|
||||||
|
// 与工作台 runRecheck 同一套收尾:先落结果与历史,再由 renderRecheck 决定
|
||||||
|
// 通过(推进版本)还是返工(设 revisionTarget)。
|
||||||
|
lastRecheckResult = data;
|
||||||
|
if(!data.checked_rewrite_version) data.checked_rewrite_version = `rv_${currentRewriteVersion}`;
|
||||||
|
pushRecheckHistory(data);
|
||||||
|
agentStage = 'recheck';
|
||||||
|
pending.push(()=>renderRecheck(data));
|
||||||
|
}else if(payload.kind==='constraint'){
|
||||||
|
// 后端把顾问要求记进 ctx 了,前端本地副本同步一份——下一轮会重发上下文,
|
||||||
|
// 两边不同步就会互相覆盖
|
||||||
|
if(pid){
|
||||||
|
const bucket = (paragraphConstraints[pid] = paragraphConstraints[pid]||[]);
|
||||||
|
if(!bucket.includes(data.note)) bucket.push(data.note);
|
||||||
|
}else if(!globalConstraints.includes(data.note)) globalConstraints.push(data.note);
|
||||||
|
}else if(payload.kind==='scaffold'){
|
||||||
|
// 走 recordExposure 而不是自己 push:复检的「有没有套用已展开的参考」
|
||||||
|
// 读的是这条记录(含段号+版本),格式不一致等于没记
|
||||||
|
if(data.scaffold){
|
||||||
|
const key = pid || pidOf(current);
|
||||||
|
helpState[key] = helpState[key] || {};
|
||||||
|
helpState[key].scaffold = true;
|
||||||
|
helpState[key].scaffoldText = data.scaffold;
|
||||||
|
recordExposure('scaffold', data.scaffold, key);
|
||||||
|
}
|
||||||
|
}else if(payload.kind==='reference'){
|
||||||
|
const text = data.reference_snippet || data.starter || '';
|
||||||
|
if(text){
|
||||||
|
const key = pid || pidOf(current);
|
||||||
|
helpState[key] = helpState[key] || {};
|
||||||
|
helpState[key].reference = true;
|
||||||
|
helpState[key].referenceText = text;
|
||||||
|
recordExposure('reference', text, key);
|
||||||
|
}
|
||||||
|
}else if(payload.kind==='translation'){
|
||||||
|
const t = data.translation || '';
|
||||||
|
// 只渲染到对话里,不写 translations[] 缓存:那份缓存按「改写稿文本相同」判新鲜,
|
||||||
|
// 而 agent 翻的可能是原文,混进去会让工作台把原文译文当成改写稿对照显示。
|
||||||
|
if(t) pending.push(()=>addAgent(`<div class="eyebrow">中文对照</div><p class="muted">${escapeHtml(pid||'')}</p><div class="summary">${escapeHtml(t)}</div>`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderChoices(container, data){
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'quick-actions';
|
||||||
|
wrap.innerHTML = `<p class="choice-q">${escapeHtml(data.question||'')}</p>` +
|
||||||
|
(data.choices||[]).map((c,i)=>`<button class="quick${i===0?' primary':''}" data-choice="${escapeHtml(c)}">${escapeHtml(c)}</button>`).join('');
|
||||||
|
container.appendChild(wrap);
|
||||||
|
wrap.querySelectorAll('[data-choice]').forEach(b=>b.onclick=()=>{
|
||||||
|
wrap.querySelectorAll('button').forEach(x=>{x.disabled=true;});
|
||||||
|
addUser(b.dataset.choice);
|
||||||
|
streamAgent(b.dataset.choice);
|
||||||
|
});
|
||||||
|
scrollChat();
|
||||||
|
}
|
||||||
|
function streamAgent(message){
|
||||||
|
if(agentBusy) return;
|
||||||
|
agentBusy = true;
|
||||||
|
const m = document.createElement('div');
|
||||||
|
m.className = 'message';
|
||||||
|
m.innerHTML = '<div class="avatar">AI</div><div class="bubble"><div class="stream-body"></div><div class="tool-cards"></div></div>';
|
||||||
|
chat.appendChild(m); scrollChat();
|
||||||
|
const body = m.querySelector('.stream-body');
|
||||||
|
const cards = m.querySelector('.tool-cards');
|
||||||
|
const toolEls = {}; const pending = [];
|
||||||
|
let text = '', failed = '';
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
agentBusy = false;
|
||||||
|
if(failed) body.insertAdjacentHTML('beforeend', `<p class="stream-error">${escapeHtml(failed)}</p>`);
|
||||||
|
if(!text && !failed && !cards.children.length && !pending.length){ removeEl(m); return; }
|
||||||
|
// agentStage 不能停在 loading*:那三个值会锁住输入框,而这一轮已经结束了。
|
||||||
|
// 工具跑过的话 applyToolPayload 已经把它推到 understanding/diagnosis/recheck。
|
||||||
|
if(agentStage==='loading'||agentStage==='diagloading'||agentStage==='recheckloading'){
|
||||||
|
agentStage = analysis ? (diagnosis ? 'diagnosis' : 'understanding') : 'idle';
|
||||||
|
}
|
||||||
|
// 流结束才进历史快照:中途刷新 = 这轮作废(半截话留着反而误导)
|
||||||
|
m._logId = ++chatLogSeq;
|
||||||
|
chatLog.push({id:m._logId, t:'msg', html:m.querySelector('.bubble').innerHTML});
|
||||||
|
pending.forEach(fn=>{ try{ fn(); }catch(e){ track('tool_payload_render_failed',{msg:e.message}); } });
|
||||||
|
bindQuickActions(); disableCompletedActions(); persistNow(); scrollChat();
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try{
|
||||||
|
const resp = await fetch('/api/chat/stream', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify(agentPayload(message||'')),
|
||||||
|
});
|
||||||
|
if(!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
await readSSE(resp, (name, data)=>{
|
||||||
|
if(name==='session_id') chatSessionId = data.session_id;
|
||||||
|
else if(name==='token'){
|
||||||
|
text += data.text; body.innerHTML = renderAgentText(escapeHtml(text)); scrollChat();
|
||||||
|
}
|
||||||
|
// thinking 不渲染:顾问要的是结论,不是模型的草稿纸
|
||||||
|
else if(name==='tool_call'){
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'tool-card running';
|
||||||
|
el.innerHTML = `<span class="spinner"></span><span class="tool-name">${escapeHtml(toolLabel(data.tool))}</span><span class="tool-sum">${escapeHtml(SLOW_TOOLS[data.tool]||'')}</span>`;
|
||||||
|
cards.appendChild(el); toolEls[data.tool_call_id] = el; scrollChat();
|
||||||
|
// 复检跑起来的时候就打快照(与工作台 runRecheck 一致):复检要几十秒,
|
||||||
|
// 这段时间顾问可能切到工作台改稿。onRewriteInput 拿 lastRecheckSnapshot.version
|
||||||
|
// 比对来决定要不要给 currentRewriteVersion +1——快照晚打就会把改过的稿当成已检稿。
|
||||||
|
if(data.tool==='recheck_rewrite'){
|
||||||
|
lastRecheckSnapshot = {version:`rv_${currentRewriteVersion}`, paragraphs: rewrites.map(r=>r||'')};
|
||||||
|
dirtyDuringRecheck = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(name==='tool_result'){
|
||||||
|
const el = toolEls[data.tool_call_id];
|
||||||
|
if(el){
|
||||||
|
el.className = 'tool-card' + (data.ok?'':' failed');
|
||||||
|
el.innerHTML = `<span class="tool-dot"></span><span class="tool-name">${escapeHtml(toolLabel(data.tool))}</span><span class="tool-sum">${escapeHtml(data.result_summary||'')}</span>`;
|
||||||
|
}
|
||||||
|
if(data.ok && data.payload) applyToolPayload(data.payload, pending);
|
||||||
|
scrollChat();
|
||||||
|
}
|
||||||
|
else if(name==='tool_request_clarify') renderChoices(cards, data);
|
||||||
|
else if(name==='error') failed = data.msg || '出了点问题,请重试';
|
||||||
|
});
|
||||||
|
}catch(e){ failed = e.message || '网络异常'; }
|
||||||
|
commit();
|
||||||
|
})();
|
||||||
|
}
|
||||||
function processChat(text){
|
function processChat(text){
|
||||||
if(!text.trim())return;
|
if(!text.trim())return;
|
||||||
if(agentStage==='loading'||agentStage==='diagloading'||agentStage==='recheckloading'){
|
if(agentBusy||agentStage==='loading'||agentStage==='diagloading'||agentStage==='recheckloading'){
|
||||||
showInputHint(); // 分析中:输入框内显示红色提示,已输入内容保留不清除
|
showInputHint(); // 分析中:输入框内显示红色提示,已输入内容保留不清除
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addUser(text.trim());$('#chatInput').value='';
|
addUser(text.trim());$('#chatInput').value='';
|
||||||
if(agentStage==='understanding'){
|
streamAgent(text.trim());
|
||||||
const applied = applyUnderstandingCorrection(text.trim());
|
|
||||||
track('understanding_corrected', applied);
|
|
||||||
persistNow();
|
|
||||||
addAgent(`<p>明白,我把这点记作<strong>${escapeHtml(applied.label)}</strong>。后面的 Diagnosis / Rewrite Guidance / Recheck 都会以你确认后的版本为准。</p><div class="summary">${escapeHtml(text.trim())}</div><p>如果各段整体理解没有其它问题,可以继续做人味分析。</p><div class="quick-actions"><button class="quick primary" data-action="confirm-understanding">确认,继续分析 →</button></div>`);
|
|
||||||
bindQuickActions();
|
|
||||||
}else if(agentStage==='diagnosis'){
|
|
||||||
const normalized = applyDiagnosisConstraint(text.trim());
|
|
||||||
persistNow();
|
|
||||||
track('diagnosis_re_generated', {constraint: normalized});
|
|
||||||
addAgent(`<p>收到,已记入:</p><div class="constraint-box">${escapeHtml(normalized)}</div><p>正在按新要求重新生成诊断,批注与各段提示将同步更新…</p>`);
|
|
||||||
runDiagnose({regen:true});
|
|
||||||
}else if(agentStage==='recheck'){
|
|
||||||
addAgent(`<p>复检结论已经给出。你可以返回对应段落继续修改后再次提交全文复检。</p>`);
|
|
||||||
}else{
|
|
||||||
addAgent(`<p>请先点击「开始分析」,或返回文书页。</p>`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fusedAiFocus(i){
|
function fusedAiFocus(i){
|
||||||
@@ -1369,9 +1549,11 @@ function onRewriteBlur(){
|
|||||||
updateParagraphSwitcherState(); // 不重建按钮:避免点击段号时 mousedown→blur 重建导致第一次点击丢失
|
updateParagraphSwitcherState(); // 不重建按钮:避免点击段号时 mousedown→blur 重建导致第一次点击丢失
|
||||||
updateProgress();
|
updateProgress();
|
||||||
}
|
}
|
||||||
function recordExposure(kind, text){
|
function recordExposure(kind, text, pid){
|
||||||
if(!text) return;
|
if(!text) return;
|
||||||
const rec = {paragraph_id: pidOf(current), text, shown_at: Date.now(), analysis_version: analysisVersion, rewrite_version: currentRewriteVersion};
|
// pid 省略 = 工作台当前段(原调用点行为)。agent 在对话里要的支架/参考可能不是当前段,
|
||||||
|
// 记错段号会让复检的「有没有套用已展开的参考」按错误的段去比对。
|
||||||
|
const rec = {paragraph_id: pid || pidOf(current), text, shown_at: Date.now(), analysis_version: analysisVersion, rewrite_version: currentRewriteVersion};
|
||||||
if(kind==='scaffold'){
|
if(kind==='scaffold'){
|
||||||
if(!scaffoldsShown.some(x=>x.paragraph_id===rec.paragraph_id && x.text===text)) scaffoldsShown.push(rec);
|
if(!scaffoldsShown.some(x=>x.paragraph_id===rec.paragraph_id && x.text===text)) scaffoldsShown.push(rec);
|
||||||
}else if(!referencesShown.some(x=>x.paragraph_id===rec.paragraph_id && x.text===text)){
|
}else if(!referencesShown.some(x=>x.paragraph_id===rec.paragraph_id && x.text===text)){
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ class LlmClient:
|
|||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
raise LLMError("缺少 OpenRouter Key:请通过 local_start.sh 启动(从 prodream_backend/.env 读取)")
|
raise LLMError("缺少 OpenRouter Key:请通过 local_start.sh 启动(从 prodream_backend/.env 读取)")
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
|
# 端点是否强制开思考(gemini-3.7-flash 是)——首次 400 后锁定,见 stream_chat
|
||||||
|
self._reasoning_locked = False
|
||||||
|
|
||||||
# -- internal ----------------------------------------------------------
|
# -- internal ----------------------------------------------------------
|
||||||
def _post(self, payload: dict[str, Any]) -> httpx.Response:
|
def _post(self, payload: dict[str, Any]) -> httpx.Response:
|
||||||
@@ -237,7 +239,158 @@ class LlmClient:
|
|||||||
)
|
)
|
||||||
return message_text(body)
|
return message_text(body)
|
||||||
|
|
||||||
|
def _stream_post(self, payload: dict[str, Any]) -> Any:
|
||||||
|
"""流式 POST:返回已进入响应体的 stream context(调用方负责关闭)。
|
||||||
|
|
||||||
|
与 _post 分开的原因:httpx 的流式响应必须在 with 块内消费完,
|
||||||
|
不能像 _post 那样把 Response 交出去——连接会被提前关闭。"""
|
||||||
|
kwargs: dict[str, Any] = {"timeout": self.timeout}
|
||||||
|
if self._transport is not None:
|
||||||
|
kwargs["transport"] = self._transport
|
||||||
|
client = httpx.Client(trust_env=False, proxy=self.proxy or None, **kwargs)
|
||||||
|
return client, client.stream(
|
||||||
|
"POST",
|
||||||
|
f"{self.base_url}/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
# -- public ------------------------------------------------------------
|
# -- public ------------------------------------------------------------
|
||||||
|
def stream_chat(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
disable_reasoning: bool = True,
|
||||||
|
max_tokens: int | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""流式对话(对话层专用)。工具层仍走 complete_json。
|
||||||
|
|
||||||
|
存在的理由:对话层要「边想边说」+ 能调工具,而 complete_json 强制
|
||||||
|
``response_format=json_object`` 且一次性返回,两者不能共存——分层后
|
||||||
|
工具层保留已验证的重试兜底,对话层只负责流式与工具编排。
|
||||||
|
|
||||||
|
逐条 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(
|
def complete_json(
|
||||||
self,
|
self,
|
||||||
system: str,
|
system: str,
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ OpenRouter pipeline. Run via ./local_start.sh.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable, Iterator
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
import agent
|
||||||
from evidence import sanitize_diagnosis
|
from evidence import sanitize_diagnosis
|
||||||
from llm import LLMError, LlmClient
|
from llm import LLMError, LlmClient
|
||||||
from prompts import (
|
from prompts import (
|
||||||
@@ -246,6 +249,57 @@ def _translate_sentences(req: TranslateRequest) -> TranslateResponse:
|
|||||||
return TranslateResponse(paragraph_id=req.paragraph_id, sentences=pairs)
|
return TranslateResponse(paragraph_id=req.paragraph_id, sentences=pairs)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
"""对话页的一轮输入。
|
||||||
|
|
||||||
|
业务上下文(原文/改写稿/顾问要求)每轮重发:顾问可能刚在工作台改完一段,
|
||||||
|
服务端不能假设自己手上那份还是最新的。session 里保留的是分析/诊断结果。"""
|
||||||
|
|
||||||
|
session_id: str | None = None
|
||||||
|
message: str = ""
|
||||||
|
prompt: str = ""
|
||||||
|
word_limit: int | None = None
|
||||||
|
paragraphs: list[str] = []
|
||||||
|
constraints: list[str] = []
|
||||||
|
rewrite_paragraphs: list[str] = []
|
||||||
|
confirmed_anchors: list[str] = []
|
||||||
|
paragraph_constraints: dict[str, list[str]] = {}
|
||||||
|
# 只有新会话的第一轮会带:页面刷新/进程重启后服务端手里没有已完成的结果,
|
||||||
|
# 缺了它们复检会静默降级(少 Pattern 对照、锚点为空)。老会话重发是纯浪费。
|
||||||
|
analysis: dict | None = None
|
||||||
|
diagnosis: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _sse(name: str, data: dict[str, Any]) -> str:
|
||||||
|
return f"event: {name}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/chat/stream")
|
||||||
|
def chat_stream(req: ChatMessage) -> StreamingResponse:
|
||||||
|
"""对话页唯一入口(SSE)。事件契约见 docs/Agent化改造方案_20260911.md §四。
|
||||||
|
|
||||||
|
用同步生成器:StreamingResponse 会把它丢进线程池,与本文件其余同步端点一致。
|
||||||
|
事件里的异常一律翻成 error 帧而不是 500 —— 流已经开了,HTTP 状态码改不了,
|
||||||
|
前端只能靠 error 帧知道失败。"""
|
||||||
|
sess = agent.get_session(req.session_id, req.model_dump())
|
||||||
|
|
||||||
|
def gen() -> Iterator[str]:
|
||||||
|
try:
|
||||||
|
for ev in agent.run_agent_stream(sess, req.message, get_client()):
|
||||||
|
yield _sse(ev["event"], ev["data"])
|
||||||
|
except LLMError as exc:
|
||||||
|
yield _sse("error", {"code": "llm_error", "msg": str(exc)})
|
||||||
|
except Exception as exc: # 兜底:不让未预期异常变成半截流
|
||||||
|
yield _sse("error", {"code": "internal", "msg": f"{exc.__class__.__name__}:{exc}"})
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
gen(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
# no-store + X-Accel-Buffering:中间层缓冲会把流攒成一坨再吐,流式就白做了
|
||||||
|
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def index() -> FileResponse:
|
def index() -> FileResponse:
|
||||||
return FileResponse(HERE / "index.html", media_type="text/html")
|
return FileResponse(HERE / "index.html", media_type="text/html")
|
||||||
|
|||||||
+1
-1
@@ -115,7 +115,7 @@ def build_diagnose_prompt(
|
|||||||
|
|
||||||
要求:
|
要求:
|
||||||
- 输出 Generative Writing Patterns,不输出 AI 概率 / AI Score / 是否 AI 写的判断。
|
- 输出 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 只给填空 / 思考顺序 / 一句不完整起笔。
|
- 输出必须紧凑(这是给顾问的人工工作流,不是写报告):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 必须是该段原文中的原句或原短语,逐字引用;禁止改写原文当作证据;禁止输出原文不存在的片段。
|
- evidence 必须是该段原文中的原句或原短语,逐字引用;禁止改写原文当作证据;禁止输出原文不存在的片段。
|
||||||
- 每个 Pattern 都要说明为什么生成模型容易这么写(why_ai_like)与为什么影响本人感(human_impact)。
|
- 每个 Pattern 都要说明为什么生成模型容易这么写(why_ai_like)与为什么影响本人感(human_impact)。
|
||||||
|
|||||||
@@ -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_context>
|
||||||
|
Essay Prompt: ...
|
||||||
|
Word Limit: ...
|
||||||
|
段落原文:
|
||||||
|
<p1>...</p1>
|
||||||
|
<p2>...</p2>
|
||||||
|
改写稿(若顾问已在工作台改写):
|
||||||
|
<p1>...</p1>
|
||||||
|
</essay_context>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **段落 id 一律用 p1/p2/p3……**,与上下文中的标签一致。讨论、引用、工具入参都用这个 id。
|
||||||
|
- 上下文是你唯一的事实来源。**不要凭记忆复述原文**——每次引用必须逐字来自上下文。
|
||||||
|
- 上下文里没有的信息(学生背景、学校要求、字数限制),不要编造;直接说没有,或调工具去取。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 工作方法:六步任务链
|
||||||
|
|
||||||
|
这是产品定义的主线,**顺序不能跳**:
|
||||||
|
|
||||||
|
1. **理解原意**——这段到底在说什么(不是评价好坏)
|
||||||
|
2. **语义锚点**——这段必须保留的核心意思,防止改写改偏
|
||||||
|
3. **识别 Pattern**——实际存在的生成式写作 Pattern,不是判断「是不是 AI 写的」
|
||||||
|
4. **映射改写动作**——每个 Pattern 对应一个具体的人工改写动作
|
||||||
|
5. **给支架**——顾问卡住时才给有限的起笔帮助,不替顾问完成关键表达
|
||||||
|
6. **全文复检**——只阻塞真正需要返工的问题
|
||||||
|
|
||||||
|
## 什么时候调哪个工具
|
||||||
|
|
||||||
|
| 顾问的意图 | 调用 |
|
||||||
|
|---|---|
|
||||||
|
| 「这篇怎么样」「帮我看看」「分析一下」 | `analyze_essay`(首次)→ 若已理解则 `diagnose_essay` |
|
||||||
|
| 「哪里像 AI」「AI 味在哪」「什么问题」 | `diagnose_essay` |
|
||||||
|
| 「这段怎么改」「p2 的改写方向」 | `get_writing_scaffold(p2)` |
|
||||||
|
| 「给我看看例子」「参考一下写法」 | `get_reference_snippet(pN)` |
|
||||||
|
| 「这句什么意思」「翻成中文」 | `translate_to_chinese(pN)` |
|
||||||
|
| 「改完了,帮我看看」「提交复检」 | `recheck_rewrite` |
|
||||||
|
| 顾问提出要求 / 纠正理解(「这段理解不对」「保留这个表达」) | `record_advisor_note` |
|
||||||
|
| 需要顾问拍板(选段落、确认方向) | `let_user_confirm` |
|
||||||
|
|
||||||
|
**`record_advisor_note` 是最容易被漏掉的一个。** 顾问说的每一句「应该……」「不要……」
|
||||||
|
「这段其实是……」都是**产品约束**(PRD §10.1:顾问约束优先级最高),必须落成记录,
|
||||||
|
否则下一轮诊断就收不到。**口头答应 ≠ 记录。**
|
||||||
|
|
||||||
|
**工具只在该用的时候用。** 顾问在闲聊、追问、澄清时,直接用你的话回答,不要为了显得勤快而调工具。
|
||||||
|
反过来——**要给出分析结论时,必须先调工具**(见「诚实性硬约束」)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 首轮盘点(agent 主动发起)
|
||||||
|
|
||||||
|
新会话第一轮,不要打招呼、不要问「有什么可以帮您」。直接按下面四块给盘点头脑,**300–500 字**:
|
||||||
|
|
||||||
|
1. **这篇在写什么**——2–3 句,用顾问能转述给学生的话,不用结构术语
|
||||||
|
2. **最像 AI 写的地方**——点到具体段落 id + 逐字原文片段,2–4 处,按影响排序
|
||||||
|
3. **改写前需要顾问确认的**——语义锚点里你不确定的、可能改偏的地方
|
||||||
|
4. **建议的下一步**——给 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` 是唯一会**暂停**的工具:调用后你会停下来等顾问点选,
|
||||||
|
顾问的选择会作为下一轮消息回到你这里。所以问题要问得能直接回答,选项要覆盖你可能采取的行动。
|
||||||
+510
@@ -0,0 +1,510 @@
|
|||||||
|
"""对话底座(agent.py / tools.py)的 hermetic 测试。
|
||||||
|
|
||||||
|
不碰真实模型:stream_chat 用脚本化 stub,工具层转调的 endpoint 处理器
|
||||||
|
通过 monkeypatch main.get_client 打桩(与 test_demo.py 同一个接缝)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import main as main_mod
|
||||||
|
from agent import MAX_TURNS, build_system_prompt, get_session, load_skill, run_agent_stream
|
||||||
|
from llm import LLMError, LlmClient
|
||||||
|
from main import app
|
||||||
|
from tools import ToolContext, execute, openai_tools
|
||||||
|
|
||||||
|
PARAGRAPHS = [
|
||||||
|
"Growing up, I always thought success was a straight line.",
|
||||||
|
"The failure taught me that resilience is the language of growth.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- stubs
|
||||||
|
class StreamStub(LlmClient):
|
||||||
|
"""脚本化 stream_chat。每个脚本项 = 一轮 LLM 的事件列表。"""
|
||||||
|
|
||||||
|
def __init__(self, script):
|
||||||
|
super().__init__(api_key="sk-test", base_url="http://stub", model="stub")
|
||||||
|
self.script = list(script)
|
||||||
|
self.seen: list[list[dict]] = []
|
||||||
|
|
||||||
|
def stream_chat(self, messages, tools=None, **kwargs):
|
||||||
|
self.seen.append([dict(m) for m in messages])
|
||||||
|
item = self.script.pop(0)
|
||||||
|
if isinstance(item, Exception):
|
||||||
|
raise item
|
||||||
|
yield from item
|
||||||
|
|
||||||
|
|
||||||
|
class CompleterStub:
|
||||||
|
"""工具层打桩:按序返回待定 JSON。"""
|
||||||
|
|
||||||
|
def __init__(self, payloads):
|
||||||
|
self.payloads = list(payloads)
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def complete_json(self, system, user, validate=None):
|
||||||
|
self.calls += 1
|
||||||
|
data = self.payloads.pop(0)
|
||||||
|
if validate is not None:
|
||||||
|
problem = validate(data)
|
||||||
|
if problem:
|
||||||
|
raise LLMError(problem)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def tool_turn(name, args=None, call_id="c1"):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "tool_calls",
|
||||||
|
"tool_calls": [
|
||||||
|
{"id": call_id, "name": name, "arguments": json.dumps(args or {})}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"type": "done", "finish_reason": "tool_calls"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def text_turn(text):
|
||||||
|
return [{"type": "text", "text": text}, {"type": "done", "finish_reason": "stop"}]
|
||||||
|
|
||||||
|
|
||||||
|
def names(events):
|
||||||
|
return [e["event"] for e in events]
|
||||||
|
|
||||||
|
|
||||||
|
def first(events, name):
|
||||||
|
return next(e for e in events if e["event"] == name)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_payload(paragraphs=PARAGRAPHS):
|
||||||
|
return {
|
||||||
|
"essay_summary": "写一次失败后的转变",
|
||||||
|
"paragraph_alignment": {},
|
||||||
|
"prompt_alignment": {"prompt_intent": "讲一次失败", "current_alignment": "基本回应"},
|
||||||
|
"paragraphs": [
|
||||||
|
{
|
||||||
|
"id": f"p{i + 1}",
|
||||||
|
"original_text": p,
|
||||||
|
"natural_meaning_zh": f"第{i + 1}段中文",
|
||||||
|
"semantic_anchor": f"锚点{i + 1}",
|
||||||
|
"optional_content_opportunity": "",
|
||||||
|
}
|
||||||
|
for i, p in enumerate(paragraphs)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def new_session(paragraphs=PARAGRAPHS, **kw):
|
||||||
|
return get_session(None, {"paragraphs": list(paragraphs), **kw})
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResp:
|
||||||
|
def __init__(self, data):
|
||||||
|
self._data = data
|
||||||
|
|
||||||
|
def model_dump(self):
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureHandler:
|
||||||
|
"""替换 endpoint 处理器,抓住工具实际发出的请求对象。"""
|
||||||
|
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
self.request = None
|
||||||
|
|
||||||
|
def __call__(self, req):
|
||||||
|
self.request = req
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 参数透传
|
||||||
|
def test_diagnose_forwards_advisor_constraints(monkeypatch):
|
||||||
|
"""顾问确认的锚点与分段要求必须到达诊断——工具化改造最容易在这里丢参数。"""
|
||||||
|
cap = CaptureHandler(FakeResp({"patterns": [], "paragraph_briefs": []}))
|
||||||
|
monkeypatch.setattr(main_mod, "diagnose", cap)
|
||||||
|
ctx = ToolContext(
|
||||||
|
paragraphs=PARAGRAPHS,
|
||||||
|
constraints=["全篇更口语"],
|
||||||
|
confirmed_anchors=["锚点A", "锚点B"],
|
||||||
|
paragraph_constraints={"p1": ["保留 secret code"]},
|
||||||
|
)
|
||||||
|
execute("diagnose_essay", {}, ctx)
|
||||||
|
|
||||||
|
assert cap.request.confirmed_anchors == ["锚点A", "锚点B"]
|
||||||
|
assert cap.request.paragraph_constraints == {"p1": ["保留 secret code"]}
|
||||||
|
assert cap.request.global_constraints == ["全篇更口语"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_diagnose_falls_back_to_analysis_anchors(monkeypatch):
|
||||||
|
"""顾问没单独确认时,退回理解阶段的锚点——保持改造前 requestDiagnosis 的行为。"""
|
||||||
|
cap = CaptureHandler(FakeResp({"patterns": [], "paragraph_briefs": []}))
|
||||||
|
monkeypatch.setattr(main_mod, "diagnose", cap)
|
||||||
|
ctx = ToolContext(paragraphs=PARAGRAPHS)
|
||||||
|
ctx.analysis = analyze_payload()
|
||||||
|
execute("diagnose_essay", {}, ctx)
|
||||||
|
assert cap.request.confirmed_anchors == ["锚点1", "锚点2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_note_reaches_diagnosis(monkeypatch):
|
||||||
|
"""顾问口头提的要求必须落成结构化约束,否则下一轮诊断收不到(PRD §10.1)。"""
|
||||||
|
ctx = ToolContext(paragraphs=PARAGRAPHS)
|
||||||
|
execute("record_advisor_note", {"note": "重点不是接受不确定", "paragraph_id": "p2"}, ctx)
|
||||||
|
execute("record_advisor_note", {"note": "全篇更口语"}, ctx)
|
||||||
|
|
||||||
|
assert ctx.paragraph_constraints == {"p2": ["重点不是接受不确定"]}
|
||||||
|
assert ctx.constraints == ["全篇更口语"]
|
||||||
|
|
||||||
|
cap = CaptureHandler(FakeResp({"patterns": [], "paragraph_briefs": []}))
|
||||||
|
monkeypatch.setattr(main_mod, "diagnose", cap)
|
||||||
|
execute("diagnose_essay", {}, ctx)
|
||||||
|
assert cap.request.paragraph_constraints == {"p2": ["重点不是接受不确定"]}
|
||||||
|
assert cap.request.global_constraints == ["全篇更口语"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_note_deduplicates_and_emits_payload():
|
||||||
|
ctx = ToolContext(paragraphs=PARAGRAPHS)
|
||||||
|
execute("record_advisor_note", {"note": "保留 secret code", "paragraph_id": "p1"}, ctx)
|
||||||
|
result = execute("record_advisor_note", {"note": "保留 secret code", "paragraph_id": "p1"}, ctx)
|
||||||
|
assert ctx.paragraph_constraints["p1"] == ["保留 secret code"], "重复记录不该堆叠"
|
||||||
|
assert result.payload["kind"] == "constraint" # 前端靠它同步本地副本
|
||||||
|
assert execute("record_advisor_note", {"note": " "}, ctx).ok is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_note_rejects_hallucinated_paragraph():
|
||||||
|
"""段号越界要报错,不能静默降级成全篇要求——那会污染整篇的约束。"""
|
||||||
|
ctx = ToolContext(paragraphs=PARAGRAPHS)
|
||||||
|
result = execute("record_advisor_note", {"note": "保留比喻", "paragraph_id": "p9"}, ctx)
|
||||||
|
assert result.ok is False
|
||||||
|
assert ctx.paragraph_constraints == {}
|
||||||
|
assert ctx.constraints == [], "越界段号不该被当成全篇要求收下"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recheck_forwards_diagnosis_and_constraints(monkeypatch):
|
||||||
|
"""不带 diagnosis,「原 Pattern 是否缓解」这一维就没有对照物。"""
|
||||||
|
cap = CaptureHandler(FakeResp({"status": "pass", "global_checks": {}, "revision_targets": []}))
|
||||||
|
monkeypatch.setattr(main_mod, "recheck", cap)
|
||||||
|
ctx = ToolContext(paragraphs=PARAGRAPHS, rewrite_paragraphs=["改1", "改2"])
|
||||||
|
ctx.diagnosis = {"overall_diagnosis": "总述", "patterns": [], "paragraph_briefs": []}
|
||||||
|
ctx.paragraph_constraints = {"p2": ["保留比喻"]}
|
||||||
|
execute("recheck_rewrite", {}, ctx)
|
||||||
|
|
||||||
|
assert cap.request.diagnosis is not None
|
||||||
|
assert cap.request.diagnosis.overall_diagnosis == "总述"
|
||||||
|
assert cap.request.paragraph_constraints == {"p2": ["保留比喻"]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- skill 注入
|
||||||
|
def test_load_skill_strips_frontmatter():
|
||||||
|
text = load_skill("hvr-rewrite")
|
||||||
|
assert not text.startswith("---"), "frontmatter 必须剥掉——它是给注册链路读的元数据,对模型是噪声"
|
||||||
|
assert "name: hvr-rewrite" not in text
|
||||||
|
assert "AI 味检测清单" in text
|
||||||
|
assert "不是 X,而是 Y" in text
|
||||||
|
assert "这些不要动手" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_prompt_carries_skill_and_essay_context():
|
||||||
|
ctx = ToolContext(prompt="Write about a failure.", word_limit=650, paragraphs=PARAGRAPHS)
|
||||||
|
prompt = build_system_prompt(ctx)
|
||||||
|
assert "AI 味检测清单" in prompt # skill 全文进 system prompt(照搬 engine 的固定注入)
|
||||||
|
assert "<essay_context>" in prompt and "</essay_context>" in prompt
|
||||||
|
assert "<p1>Growing up" in prompt
|
||||||
|
assert "Write about a failure." in prompt
|
||||||
|
assert "已完成步骤:无" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_prompt_marks_completed_steps():
|
||||||
|
ctx = ToolContext(paragraphs=PARAGRAPHS)
|
||||||
|
ctx.analysis = {"paragraphs": []}
|
||||||
|
assert "已完成理解" in build_system_prompt(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 工具循环
|
||||||
|
def test_tool_loop_runs_tool_then_answers(monkeypatch):
|
||||||
|
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
|
||||||
|
stub = StreamStub([tool_turn("analyze_essay"), text_turn("这篇的问题在结尾。")])
|
||||||
|
sess = new_session()
|
||||||
|
|
||||||
|
events = list(run_agent_stream(sess, "帮我看看这篇", stub))
|
||||||
|
|
||||||
|
assert names(events)[:2] == ["meta", "session_id"]
|
||||||
|
call = first(events, "tool_call")
|
||||||
|
assert call["data"]["tool"] == "analyze_essay"
|
||||||
|
result = first(events, "tool_result")
|
||||||
|
assert result["data"]["ok"] is True
|
||||||
|
assert result["data"]["payload"]["kind"] == "analysis" # 前端靠 payload 渲染理解卡
|
||||||
|
assert first(events, "token")["data"]["text"] == "这篇的问题在结尾。"
|
||||||
|
assert events[-1] == {"event": "done", "data": {"finish_reason": "stop"}}
|
||||||
|
assert len(stub.seen) == 2, "工具轮 + 回答轮 = 两次 LLM 调用"
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_and_tool_call_in_one_turn(monkeypatch):
|
||||||
|
"""同一轮里既有正文又有工具调用:正文要流出去,工具也要执行。
|
||||||
|
|
||||||
|
SKILL.md 的首轮盘点要求「先写盘点正文,再在同一次回复里调 let_user_confirm」——
|
||||||
|
这条路走不通的话,顾问屏幕上只剩几张工具卡片,一个字都看不到。"""
|
||||||
|
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
|
||||||
|
mixed = [
|
||||||
|
{"type": "text", "text": "这篇在写什么:"},
|
||||||
|
{
|
||||||
|
"type": "tool_calls",
|
||||||
|
"tool_calls": [{"id": "c1", "name": "analyze_essay", "arguments": "{}"}],
|
||||||
|
},
|
||||||
|
{"type": "done", "finish_reason": "tool_calls"},
|
||||||
|
]
|
||||||
|
stub = StreamStub([mixed, text_turn("盘点如上。")])
|
||||||
|
events = list(run_agent_stream(new_session(), "请开始首轮盘点。", stub))
|
||||||
|
|
||||||
|
streamed = "".join(e["data"]["text"] for e in events if e["event"] == "token")
|
||||||
|
assert streamed == "这篇在写什么:盘点如上。"
|
||||||
|
assert [e["data"]["tool"] for e in events if e["event"] == "tool_call"] == ["analyze_essay"]
|
||||||
|
# 带工具调用的那轮,正文也必须落进历史,否则下一轮模型不记得自己说过什么
|
||||||
|
assert stub.seen[1][-2]["content"] == "这篇在写什么:"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_result_in_history_is_summary_not_full_payload(monkeypatch):
|
||||||
|
"""诊断 JSON 有 ~2.5k tokens,原样回灌会让每轮成本滚雪球——进历史的必须是摘要。"""
|
||||||
|
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
|
||||||
|
stub = StreamStub([tool_turn("analyze_essay"), text_turn("好。")])
|
||||||
|
list(run_agent_stream(new_session(), "看看", stub))
|
||||||
|
|
||||||
|
second_turn = stub.seen[1]
|
||||||
|
tool_msg = next(m for m in second_turn if m.get("role") == "tool")
|
||||||
|
assert "语义锚点" in tool_msg["content"] # 摘要内容
|
||||||
|
assert tool_msg["content"] not in json.dumps(analyze_payload()) # 不是原始 payload
|
||||||
|
assert len(tool_msg["content"]) < 800
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_is_stashed_for_later_tools(monkeypatch):
|
||||||
|
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
|
||||||
|
stub = StreamStub([tool_turn("analyze_essay"), text_turn("好。")])
|
||||||
|
sess = new_session()
|
||||||
|
list(run_agent_stream(sess, "看看", stub))
|
||||||
|
assert sess["ctx"].analysis is not None # 后续工具与前端上下文复用
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_tool_calls_all_execute(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main_mod, "get_client", lambda: CompleterStub([analyze_payload()])
|
||||||
|
)
|
||||||
|
stub = StreamStub(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "tool_calls",
|
||||||
|
"tool_calls": [
|
||||||
|
{"id": "c1", "name": "analyze_essay", "arguments": "{}"},
|
||||||
|
{"id": "c2", "name": "translate_to_chinese", "arguments": '{"paragraph_id": "p1"}'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"type": "done", "finish_reason": "tool_calls"},
|
||||||
|
],
|
||||||
|
text_turn("好了。"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
events = list(run_agent_stream(new_session(), "看看", stub))
|
||||||
|
calls = [e["data"]["tool"] for e in events if e["event"] == "tool_call"]
|
||||||
|
assert calls == ["analyze_essay", "translate_to_chinese"]
|
||||||
|
assert len([e for e in events if e["event"] == "tool_result"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 交互型工具
|
||||||
|
def test_let_user_confirm_pauses_the_loop():
|
||||||
|
stub = StreamStub(
|
||||||
|
[tool_turn("let_user_confirm", {"question": "先改哪一段?", "choices": ["p1", "p2"]})]
|
||||||
|
)
|
||||||
|
events = list(run_agent_stream(new_session(), "开始", stub))
|
||||||
|
|
||||||
|
clarify = first(events, "tool_request_clarify")
|
||||||
|
assert clarify["data"]["question"] == "先改哪一段?"
|
||||||
|
assert clarify["data"]["choices"] == ["p1", "p2"]
|
||||||
|
assert events[-1] == {"event": "done", "data": {"finish_reason": "await_user"}}
|
||||||
|
assert len(stub.seen) == 1, "暂停后不应再调 LLM——等顾问点选"
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_requires_question_and_choices():
|
||||||
|
result = execute("let_user_confirm", {"question": "?", "choices": []}, ToolContext())
|
||||||
|
assert result.ok is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 失败路径
|
||||||
|
def test_tool_failure_does_not_kill_the_turn():
|
||||||
|
"""工具失败必须让 LLM 看见并如实转述(SKILL.md 诚实性硬约束第 4 条)。"""
|
||||||
|
stub = StreamStub([tool_turn("recheck_rewrite"), text_turn("还没有改写稿,先改。")])
|
||||||
|
events = list(run_agent_stream(new_session(), "复检一下", stub))
|
||||||
|
|
||||||
|
result = first(events, "tool_result")
|
||||||
|
assert result["data"]["ok"] is False
|
||||||
|
assert "改写稿" in result["data"]["result_summary"]
|
||||||
|
assert first(events, "token")["data"]["text"] == "还没有改写稿,先改。"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_tool_is_reported_not_raised():
|
||||||
|
stub = StreamStub([tool_turn("no_such_tool"), text_turn("我换个办法。")])
|
||||||
|
events = list(run_agent_stream(new_session(), "?", stub))
|
||||||
|
assert first(events, "tool_result")["data"]["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_tool_arguments_do_not_crash():
|
||||||
|
stub = StreamStub(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "tool_calls",
|
||||||
|
"tool_calls": [{"id": "c1", "name": "analyze_essay", "arguments": "{not json"}],
|
||||||
|
},
|
||||||
|
{"type": "done", "finish_reason": "tool_calls"},
|
||||||
|
],
|
||||||
|
text_turn("好。"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
events = list(run_agent_stream(new_session(), "?", stub))
|
||||||
|
assert first(events, "tool_call")["data"]["args"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_essay_fails_closed_without_calling_llm():
|
||||||
|
stub = StreamStub([])
|
||||||
|
events = list(run_agent_stream(new_session(paragraphs=[]), "看看", stub))
|
||||||
|
assert first(events, "error")["data"]["code"] == "no_essay"
|
||||||
|
assert stub.seen == [], "没有原文就不该付 LLM 调用的钱"
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_error_becomes_error_frame():
|
||||||
|
stub = StreamStub([LLMError("调用大模型失败(HTTP 402)")])
|
||||||
|
events = list(run_agent_stream(new_session(), "看看", stub))
|
||||||
|
err = first(events, "error")
|
||||||
|
assert err["data"]["code"] == "llm_error"
|
||||||
|
assert "402" in err["data"]["msg"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_turns_guard(monkeypatch):
|
||||||
|
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()] * MAX_TURNS))
|
||||||
|
stub = StreamStub([tool_turn("analyze_essay", call_id=f"c{i}") for i in range(MAX_TURNS)])
|
||||||
|
events = list(run_agent_stream(new_session(), "一直调工具", stub))
|
||||||
|
assert first(events, "error")["data"]["code"] == "max_turns"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 会话
|
||||||
|
def test_session_reuses_context_and_history(monkeypatch):
|
||||||
|
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
|
||||||
|
stub = StreamStub([tool_turn("analyze_essay"), text_turn("好。")])
|
||||||
|
sess = new_session()
|
||||||
|
list(run_agent_stream(sess, "看看", stub))
|
||||||
|
|
||||||
|
again = get_session(sess["sid"], {"paragraphs": PARAGRAPHS})
|
||||||
|
assert again is sess
|
||||||
|
assert again["ctx"].analysis is not None, "续接会话不能丢已完成的分析"
|
||||||
|
assert any(m.get("role") == "user" for m in again["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_refresh_keeps_completed_work(monkeypatch):
|
||||||
|
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
|
||||||
|
sess = new_session()
|
||||||
|
list(run_agent_stream(sess, "看看", StreamStub([tool_turn("analyze_essay"), text_turn("好。")])))
|
||||||
|
|
||||||
|
# 顾问在工作台改完了稿,前端重发上下文
|
||||||
|
get_session(sess["sid"], {"paragraphs": PARAGRAPHS, "rewrite_paragraphs": ["改后1", "改后2"]})
|
||||||
|
assert sess["ctx"].rewrite_paragraphs == ["改后1", "改后2"]
|
||||||
|
assert sess["ctx"].analysis is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_session_id_starts_fresh_instead_of_erroring():
|
||||||
|
sess = get_session("deadbeefdeadbeef", {"paragraphs": PARAGRAPHS})
|
||||||
|
assert sess["sid"] != "deadbeefdeadbeef"
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_session_hydrates_analysis_and_diagnosis():
|
||||||
|
"""刷新/进程重启后前端新开会话,第一轮必须把已有的分析/诊断带过来。
|
||||||
|
|
||||||
|
丢了它们不会报错——复检会静默降级(少 Pattern 对照、锚点为空),
|
||||||
|
属于「不报错的错」,所以这里钉死。"""
|
||||||
|
analysis = analyze_payload()
|
||||||
|
diagnosis = {"overall_diagnosis": "像 AI", "patterns": [], "paragraph_briefs": []}
|
||||||
|
sess = get_session(None, {"paragraphs": PARAGRAPHS, "analysis": analysis, "diagnosis": diagnosis})
|
||||||
|
assert sess["ctx"].analysis == analysis
|
||||||
|
assert sess["ctx"].diagnosis == diagnosis
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_does_not_wipe_hydrated_results():
|
||||||
|
"""有了 session_id 之后前端不再重发分析/诊断,重发也不该把它们抹掉。"""
|
||||||
|
sess = get_session(None, {"paragraphs": PARAGRAPHS, "analysis": analyze_payload()})
|
||||||
|
get_session(sess["sid"], {"paragraphs": PARAGRAPHS, "rewrite_paragraphs": ["改后1", "改后2"]})
|
||||||
|
assert sess["ctx"].analysis is not None
|
||||||
|
assert sess["ctx"].rewrite_paragraphs == ["改后1", "改后2"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 工具声明
|
||||||
|
def test_tool_declarations_are_openai_shaped():
|
||||||
|
specs = openai_tools()
|
||||||
|
assert {s["function"]["name"] for s in specs} == {
|
||||||
|
"analyze_essay",
|
||||||
|
"diagnose_essay",
|
||||||
|
"get_writing_scaffold",
|
||||||
|
"get_reference_snippet",
|
||||||
|
"translate_to_chinese",
|
||||||
|
"recheck_rewrite",
|
||||||
|
"record_advisor_note",
|
||||||
|
"let_user_confirm",
|
||||||
|
}
|
||||||
|
for spec in specs:
|
||||||
|
assert spec["type"] == "function"
|
||||||
|
fn = spec["function"]
|
||||||
|
assert fn["description"].strip()
|
||||||
|
assert fn["parameters"]["type"] == "object"
|
||||||
|
|
||||||
|
|
||||||
|
def test_paragraph_id_is_normalized():
|
||||||
|
"""模型给 'P2'/'2'/'p2' 都该认;越界或给不出就返回空,不猜。"""
|
||||||
|
ctx = ToolContext(paragraphs=PARAGRAPHS)
|
||||||
|
assert ctx.paragraph("p2") == PARAGRAPHS[1]
|
||||||
|
assert ctx.paragraph("P2") == PARAGRAPHS[1]
|
||||||
|
assert ctx.paragraph("2") == PARAGRAPHS[1]
|
||||||
|
assert ctx.paragraph("9") == ""
|
||||||
|
assert ctx.paragraph("") == ""
|
||||||
|
assert ctx.paragraph("third") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_tool_rejects_unknown_paragraph():
|
||||||
|
result = execute("get_writing_scaffold", {"paragraph_id": "p9"}, ToolContext(paragraphs=PARAGRAPHS))
|
||||||
|
assert result.ok is False
|
||||||
|
assert "找不到段落" in result.error
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- HTTP 端点
|
||||||
|
def test_chat_stream_endpoint_emits_wire_format(monkeypatch):
|
||||||
|
monkeypatch.setattr(main_mod, "get_client", lambda: CompleterStub([analyze_payload()]))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
main_mod.agent,
|
||||||
|
"run_agent_stream",
|
||||||
|
lambda sess, msg, client: iter(
|
||||||
|
[
|
||||||
|
{"event": "meta", "data": {"trace_id": "t1"}},
|
||||||
|
{"event": "token", "data": {"text": "你好"}},
|
||||||
|
{"event": "done", "data": {"finish_reason": "stop"}},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with TestClient(app) as c:
|
||||||
|
r = c.post("/api/chat/stream", json={"message": "hi", "paragraphs": PARAGRAPHS})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"].startswith("text/event-stream")
|
||||||
|
assert 'event: token\ndata: {"text": "你好"}' in r.text
|
||||||
|
assert r.text.rstrip().endswith("data: {\"finish_reason\": \"stop\"}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_stream_reports_client_error_as_frame(monkeypatch):
|
||||||
|
def boom():
|
||||||
|
raise LLMError("缺少 OpenRouter Key")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main_mod, "get_client", boom)
|
||||||
|
with TestClient(app) as c:
|
||||||
|
r = c.post("/api/chat/stream", json={"message": "hi", "paragraphs": PARAGRAPHS})
|
||||||
|
assert r.status_code == 200, "流已开,状态码改不了——失败靠 error 帧告诉前端"
|
||||||
|
assert "event: error" in r.text
|
||||||
|
assert "缺少 OpenRouter Key" in r.text
|
||||||
@@ -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="")
|
||||||
Reference in New Issue
Block a user