Compare commits
18
Commits
6d17a01f73
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bc02a7f10 | ||
|
|
76d3b30e7d | ||
|
|
bdc6a53f81 | ||
|
|
94a26227c1 | ||
|
|
493c7f6416 | ||
|
|
d0f2901875 | ||
|
|
6189ee87d8 | ||
|
|
a81b366d71 | ||
|
|
b697b74e3b | ||
|
|
ad29ea3fc3 | ||
|
|
39d49d204f | ||
|
|
3a4c7ab728 | ||
|
|
a2c607c1be | ||
|
|
938a9e9b22 | ||
|
|
d4554eb1af | ||
|
|
6e0db821a8 | ||
|
|
85a2eb720a | ||
|
|
36b7e601e7 |
@@ -7,3 +7,6 @@ __pycache__/
|
|||||||
# 运行日志与 E2E 测试产物
|
# 运行日志与 E2E 测试产物
|
||||||
*.log
|
*.log
|
||||||
_e2e_*.js
|
_e2e_*.js
|
||||||
|
|
||||||
|
# 本地工作文档(需求回写/自测记录/实现方案等,仅供本机查阅,不入库)
|
||||||
|
docs/
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# 文书工作台 Agent 优化 — 交付说明(v1.0)
|
||||||
|
|
||||||
|
> 日期:2026-09-01 | 涉及仓库:prodream-next(前端)、prodream_backend(后端)
|
||||||
|
> 方案文档:[docs/essay-agent-optimization-plan.md](essay-agent-optimization-plan.md)(已实施,状态头已更新)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、完成状态总览
|
||||||
|
|
||||||
|
| # | 需求 | 状态 | 关键实现 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Agent 对话进文书工作台(essay-chat skill) | ✅ 已实施 | tab6 替换为 DreamiChatV2 嵌入;后端 essay-chat skill + 控制面映射;`<<<ACTIONS>>>` 锚点落回编辑文档 |
|
||||||
|
| 2 | humanizer 规则融入(AI 味识别与优化) | ✅ 已实施 | SKILL.md 新增「AI 味检查」章节:参照 humanizer v2.11.2 裁剪的检测清单 + 逐段扫描→报告→确认后改写流程 |
|
||||||
|
| 3 | 文书评估常驻卡片 | ✅ 已实施 | 评估结果 zustand 常驻 + 历史时间线(GET /evaluation/history)+ 「去对话改写」闭环 |
|
||||||
|
| 4 | 体验优化(翻译 / 字数提示 / 汇总建议) | ✅ 已实施 | assistant 回复翻译按钮;输入框旁目标字数提示(BE 注入 word_count,改写自动带字数约束);历史评估构造汇总建议 |
|
||||||
|
| 5 | 输入法误发送 bug | ✅ 已实施 | 5 处 onKeyDown 加 `isComposing` 守卫 |
|
||||||
|
|
||||||
|
## 二、commit 对照
|
||||||
|
|
||||||
|
### 前端 prodream-next(按顺序)
|
||||||
|
|
||||||
|
| 阶段 | commit | 内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| A | `563a13cc8` | fix: 输入法组合期间回车误发送(5 处 onKeyDown 守卫) |
|
||||||
|
| C | `1153a1f61` | feat: essay 工作台 tab6 替换为 DreamiChatV2 essay-chat 嵌入 + ACTIONS 锚点落回 |
|
||||||
|
| D | `8ef4f0e75` | feat: 文书评估常驻卡片 + 历史时间线 + 建议→对话改写闭环 |
|
||||||
|
| F | `3aaf10bf6` | feat: essay workbench UX polish — translation buttons, word-count hint, history suggestions |
|
||||||
|
|
||||||
|
### 后端 prodream_backend(按顺序)
|
||||||
|
|
||||||
|
| 阶段 | commit | 内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| B | `250e39454` | feat: essay-chat skill 骨架 + get_essay_evaluation 工具 |
|
||||||
|
| C | `88969a639` | feat: 控制面 chat.py essay-chat 映射 + toolset 切换 + context 富化 + ACTIONS 剥离回传 |
|
||||||
|
| D | `852236107` | feat: 评估历史接口 GET /essay/{id}/evaluation/history + SKILL 首轮带指令 |
|
||||||
|
| E | `d68b45386` | feat: essay-chat skill AI-flavor check(humanizer 改编清单 + 检测/确认/改写流程) |
|
||||||
|
| F | `82729a5d0` | feat: inject essay word_count into essay-chat context + SKILL context note |
|
||||||
|
|
||||||
|
> 注:阶段 B(skill 骨架)为纯后端改动,前端无对应 commit。
|
||||||
|
|
||||||
|
## 三、验收对照
|
||||||
|
|
||||||
|
### 需求 1:Agent 对话
|
||||||
|
|
||||||
|
| 验收点 | 状态 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| tab6(竖列底部 Dreami 按钮)进入即自动开聊 | ✅ 静态验证 | `autoStart` + `hideEmptyHero`,空首轮触发 SKILL 首轮盘点(agent_initiated) |
|
||||||
|
| 对话上下文带文书全文 / 题目 / 目标字数 / 评估 | ✅ 静态验证 | BE 按 essay_id 注入 current_document/essay_prompt/word_count/evaluation 到最后一条 user 消息 |
|
||||||
|
| 对话里改文书:确认后落回 | ✅ 静态验证 | SKILL「建议落地」先给改法→用户确认→`<<<ACTIONS>>>` 锚点替换 Tiptap 段落→useSaveEssay 落库 |
|
||||||
|
| 锚点未命中不猜测 | ✅ 静态验证 | `applyEssayActionsToEditor` 唯一命中才应用,未命中 toast 提示 |
|
||||||
|
| 语言跟随界面 locale | ✅ 静态验证 | taskMetadata.context.locale 注入 + 回复语言约束 |
|
||||||
|
| 浏览器实测 | ⏳ 待验证 | 见风险 5 |
|
||||||
|
|
||||||
|
### 需求 2:AI 味检查
|
||||||
|
|
||||||
|
| 验收点 | 状态 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 检测清单覆盖 humanizer 35 条(裁剪) | ✅ 静态验证 | 内容/语言/风格三类清单 + 反误报清单,出处标注 humanizer v2.11.2 |
|
||||||
|
| 逐段扫描→报告(模式+原文引用+改法) | ✅ 静态验证 | SKILL「AI 味检查」检测流程 |
|
||||||
|
| 确认后改写,不改事实/删词不删意 | ✅ 静态验证 | 改写四原则 |
|
||||||
|
| 与 GPTZero 按钮互补不冲突 | ✅ 静态验证 | SKILL 互补说明 |
|
||||||
|
|
||||||
|
### 需求 3:评估常驻卡片
|
||||||
|
|
||||||
|
| 验收点 | 状态 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 评估完结果常驻(刷新/重进恢复) | ✅ 静态验证 | zustand 空态自动恢复 history[0] |
|
||||||
|
| 多次评估可回看历史 | ✅ 静态验证 | GET /essay/{id}/evaluation/history + 历史时间线折叠列表 |
|
||||||
|
| 历史项可选回看 | ✅ 静态验证 | onSelect 切换 result + 构造只读建议 |
|
||||||
|
| 从建议「去对话改写」 | ✅ 静态验证 | handleChatRewrite 暂存指令→切 tab6;历史建议 origin 留空→guard 引导走对话 |
|
||||||
|
|
||||||
|
### 需求 4:体验优化
|
||||||
|
|
||||||
|
| 验收点 | 状态 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| assistant 回复一键翻译(复用 translate API) | ✅ 静态验证 | assistantTranslate prop(仅 essay-chat 传,其他场景行为不变)+ 译文消息下方展开/收起 |
|
||||||
|
| 输入框旁目标字数提示 | ✅ 静态验证 | expect_word_count + editor 词数实时;超出变红;无字数要求题不显示 |
|
||||||
|
| 改写指令自动带字数约束 | ✅ 静态验证 | BE 注入 word_count 字段 + SKILL 换算单段比例逻辑 |
|
||||||
|
| 汇总建议:一句话行动项 | ✅ 静态验证 | improvement 字段既有能力,未新造 |
|
||||||
|
| 历史里能看到上次评估建议并可再次进入改写 | ✅ 静态验证 | handleSelectHistory 构造 FeedbackImprovement 列表 + 「去对话改写」按钮 |
|
||||||
|
|
||||||
|
### 需求 5:输入法 bug
|
||||||
|
|
||||||
|
| 验收点 | 状态 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 中文输入组合期间回车不误发送 | ✅ 静态验证 | 5 处 `onKeyDown` 加 `e.nativeEvent.isComposing` 守卫;tsc pass |
|
||||||
|
| 组合完成后回车正常发送 | ✅ 静态验证 | 组合状态 false 走原逻辑 |
|
||||||
|
|
||||||
|
## 四、验证记录
|
||||||
|
|
||||||
|
- 后端:`py_compile` 全部改动文件 pass
|
||||||
|
- 前端:`tsc --noEmit`(主仓 node_modules 绝对路径)exit 0
|
||||||
|
- i18n:cn.json / en.json JSON.parse pass,key 一一对应
|
||||||
|
- 浏览器端到端验证(pre.prodream.cn):⏳ 未做 — 需要测试账号(见风险 5)
|
||||||
|
|
||||||
|
## 五、改动文件清单
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- `components/editor/rightbar/chatbot/Chatbot.tsx` — essay-chat 嵌入 + ACTIONS 落回 + 字数提示 + assistantTranslate
|
||||||
|
- `components/dreami/DreamiChatV2.tsx` — assistantTranslate / composerHint 可选 prop + 翻译按钮 UI
|
||||||
|
- `components/editor/rightbar/feedback/Feedback.tsx` — 常驻恢复 + 历史时间线 + handleSelectHistory
|
||||||
|
- `components/editor/rightbar/feedback/Result.tsx` — 评估报告翻译按钮 + 译文区
|
||||||
|
- `components/editor/rightbar/feedback/Improvement.tsx` — 历史建议 origin guard
|
||||||
|
- `components/dreami/dreamiAdapter.ts` — EssayAnchorAction 类型透传
|
||||||
|
- 输入法 5 处 onKeyDown(chat 输入框所在组件)
|
||||||
|
- `dictionaries/cn.json` / `dictionaries/en.json` — 新增 key
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- `dreami/control-plane/dreami_control_plane/api/chat.py` — essay-chat task 映射 + toolset + context 富化 + `<<<ACTIONS>>>` 剥离回传 + word_count 注入
|
||||||
|
- `dreami_skills/essay-chat/SKILL.md` — 骨架 + 首轮盘点 + AI 味检查章节
|
||||||
|
- 评估历史接口 + `get_essay_evaluation` 工具
|
||||||
|
|
||||||
|
## 六、风险与未做项
|
||||||
|
|
||||||
|
1. **浏览器验证未做**(风险 5):pre.prodream.cn 需要测试账号。联调验证时需要你提供账号,或由你自行走查。
|
||||||
|
2. **对话改写需用户确认**:已按方案(先给改法→确认→落回)实现,未绕过确认——这是刻意行为,不是缺陷。
|
||||||
|
3. **评估为异步任务**:对话内 get_essay_evaluation 走工具调用,结果以工具返回注入;AI 输出等待提示由 skill 指令约束。耗时表现需浏览器实测确认。
|
||||||
|
4. **humanizer 为参照非照搬**:35 条按文书场景裁剪(检测保留、改写挑选),详见 SKILL「AI 味检查」章节出处标注。
|
||||||
|
5. **未动评估 prompt 体系**:评估质量体系(招生官视角 + 8 维度)原样保留。
|
||||||
+595
-106
@@ -46,24 +46,38 @@ 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}
|
||||||
|
|
||||||
/* Workbench — two columns */
|
/* Workbench — two columns */
|
||||||
.workbench-shell{min-height:calc(100vh - 54px);background:#f6f7fb}
|
.workbench-shell{height:calc(100vh - 54px);display:flex;flex-direction:column;overflow:hidden;background:#f6f7fb}
|
||||||
.work-head{background:#fff;border-bottom:1px solid var(--line);padding:15px 24px;position:sticky;top:54px;z-index:30}
|
.work-head{background:#fff;border-bottom:1px solid var(--line);padding:15px 24px;position:relative;z-index:30;flex-shrink:0}
|
||||||
.work-head-inner{max-width:1540px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
.work-head-inner{max-width:1540px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:24px}
|
||||||
.work-title{font-size:21px;font-weight:790;line-height:1.35}.work-sub{font-size:12px;color:var(--muted);margin-top:4px}
|
.work-title{font-size:21px;font-weight:790;line-height:1.35}.work-sub{font-size:12px;color:var(--muted);margin-top:4px}
|
||||||
.progress-mini{display:flex;gap:12px;align-items:center;min-width:270px;justify-content:flex-end}.progress-track{width:138px;height:6px;background:#eceef4;border-radius:999px;overflow:hidden}.progress-track div{height:100%;width:0;background:var(--brand);transition:.25s}.progress-txt{font-size:12px;color:#4d5569;font-weight:750;white-space:nowrap}
|
.progress-mini{display:flex;gap:12px;align-items:center;min-width:270px;justify-content:flex-end}.progress-track{width:138px;height:6px;background:#eceef4;border-radius:999px;overflow:hidden}.progress-track div{height:100%;width:0;background:var(--brand);transition:.25s}.progress-txt{font-size:12px;color:#4d5569;font-weight:750;white-space:nowrap}.progress-txt.over{color:#b53a30}
|
||||||
.constraint-entry{display:flex;flex-direction:column;align-items:flex-end;gap:8px}
|
.constraint-entry{display:flex;flex-direction:column;align-items:flex-end;gap:8px}
|
||||||
.constraint-input-row{display:flex;flex-direction:column;gap:8px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:10px;box-shadow:var(--shadow);width:340px;position:absolute;right:24px;top:60px;z-index:40}
|
.constraint-input-row{display:flex;flex-direction:column;gap:8px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:10px;box-shadow:var(--shadow);width:340px;position:absolute;right:24px;top:60px;z-index:40}
|
||||||
.constraint-input-row textarea{width:100%;min-height:64px;border:1px solid #e2e4ed;border-radius:9px;padding:8px 10px;font-size:12px;outline:0;resize:vertical}
|
.constraint-input-row textarea{width:100%;min-height:64px;border:1px solid #e2e4ed;border-radius:9px;padding:8px 10px;font-size:12px;outline:0;resize:vertical}
|
||||||
.constraint-input-row .row-actions{display:flex;gap:8px;justify-content:flex-end}
|
.constraint-input-row .row-actions{display:flex;gap:8px;justify-content:flex-end}
|
||||||
.work-grid{max-width:1540px;margin:0 auto;padding:16px 24px 96px;display:grid;grid-template-columns:minmax(0,46fr) minmax(0,54fr);gap:14px;align-items:start}
|
.work-grid{flex:1;min-height:0;width:100%;max-width:1540px;margin:0 auto;padding:16px 24px;display:grid;grid-template-columns:minmax(0,46fr) minmax(0,54fr);gap:14px;align-items:stretch;overflow:hidden}
|
||||||
.pane-card{background:#fff;border:1px solid var(--line);border-radius:14px;overflow:hidden;min-height:690px}.pane-head{height:54px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-weight:740;font-size:13px}.pane-body{padding:18px}
|
.pane-card{background:#fff;border:1px solid var(--line);border-radius:14px;overflow:hidden}.pane-head{height:54px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-weight:740;font-size:13px}.pane-body{padding:18px}
|
||||||
.sticky-pane{position:sticky;top:132px;max-height:calc(100vh - 150px);overflow:auto}
|
.sticky-pane{min-height:0;display:flex;flex-direction:column;overflow:hidden}.sticky-pane>.pane-head{flex-shrink:0}.sticky-pane>.pane-body{flex:1;min-height:0;overflow-y:auto}
|
||||||
|
|
||||||
.original-pane-head{height:58px;gap:12px;flex-wrap:nowrap}.original-pane-title{display:flex;align-items:center;gap:7px;white-space:nowrap}
|
.original-pane-head{height:54px;gap:12px;flex-wrap:nowrap}.original-pane-title{display:flex;align-items:center;gap:7px;white-space:nowrap}
|
||||||
.paragraph-switcher{display:flex;align-items:center;gap:6px;margin-left:auto;overflow-x:auto;max-width:min(420px,42vw);padding-bottom:1px}
|
.paragraph-switcher{display:flex;align-items:center;gap:6px;margin-left:auto;overflow-x:auto;max-width:min(420px,42vw);padding-bottom:1px}
|
||||||
.para-tab{width:36px;height:34px;border:1px solid #dde0e8;border-radius:9px;background:#fff;color:#626a7c;font-size:13px;font-weight:780;display:grid;place-items:center;padding:0;transition:.15s;flex:none}.para-tab:hover{border-color:#b9baff;background:#f8f8ff;color:#4541d7}.para-tab.active{background:var(--brand);border-color:var(--brand);color:#fff;box-shadow:0 4px 10px rgba(86,84,245,.14)}.para-tab.has-copy:not(.active){border-color:#cfe4dc;color:#26765a;background:#f7fbf9}
|
.para-tab{width:36px;height:34px;border:1px solid #dde0e8;border-radius:9px;background:#fff;color:#626a7c;font-size:13px;font-weight:780;display:grid;place-items:center;padding:0;transition:.15s;flex:none}.para-tab:hover{border-color:#b9baff;background:#f8f8ff;color:#4541d7}.para-tab.active{background:var(--brand);border-color:var(--brand);color:#fff;box-shadow:0 4px 10px rgba(86,84,245,.14)}.para-tab.has-copy:not(.active){border-color:#cfe4dc;color:#26765a;background:#f7fbf9}
|
||||||
.view-toggle{display:inline-flex;padding:3px;background:#eff1f5;border-radius:9px;gap:2px;flex:none;margin-left:4px}.view-btn{border:0;background:transparent;color:#737b8d;border-radius:7px;padding:6px 10px;font-size:11px;font-weight:700}.view-btn.active{background:#fff;color:#3936c9;box-shadow:0 1px 4px rgba(28,31,45,.08)}
|
.view-toggle{display:inline-flex;padding:3px;background:#eff1f5;border-radius:9px;gap:2px;flex:none;margin-left:4px}.view-btn{border:0;background:transparent;color:#737b8d;border-radius:7px;padding:6px 10px;font-size:11px;font-weight:700}.view-btn.active{background:#fff;color:#3936c9;box-shadow:0 1px 4px rgba(28,31,45,.08)}
|
||||||
@@ -82,13 +96,13 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
|
|||||||
|
|
||||||
.full-original-list{display:flex;flex-direction:column;gap:9px}.mode-note{font-size:11px;color:#9299aa;margin:0 0 2px}.full-paragraph{border:1px solid transparent;border-radius:11px;padding:12px 13px;transition:.15s;cursor:pointer;position:relative}.full-paragraph:hover{background:#fafaff;border-color:#e8e8ff}.full-paragraph.active{background:#f7f7ff;border-color:#dedfff}.full-paragraph.active:before{content:"";position:absolute;left:-1px;top:12px;bottom:12px;width:3px;border-radius:3px;background:var(--brand)}.full-paragraph-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:6px}.full-paragraph-label{font-size:11.5px;font-weight:780;color:#42485b}.full-paragraph-state{font-size:10px;color:#8f96a6}.full-paragraph.active .full-paragraph-state{color:var(--brand);font-weight:750}.full-paragraph-copy{font:13.5px/1.72 Georgia,"Times New Roman",serif;color:#4a5060;margin:0}.full-paragraph-foot{display:flex;justify-content:flex-end;margin-top:7px}.full-issues-button{border:0;background:transparent;color:#6663d9;font-size:10.5px;font-weight:750;padding:2px 0}.full-issues-button:hover{text-decoration:underline}
|
.full-original-list{display:flex;flex-direction:column;gap:9px}.mode-note{font-size:11px;color:#9299aa;margin:0 0 2px}.full-paragraph{border:1px solid transparent;border-radius:11px;padding:12px 13px;transition:.15s;cursor:pointer;position:relative}.full-paragraph:hover{background:#fafaff;border-color:#e8e8ff}.full-paragraph.active{background:#f7f7ff;border-color:#dedfff}.full-paragraph.active:before{content:"";position:absolute;left:-1px;top:12px;bottom:12px;width:3px;border-radius:3px;background:var(--brand)}.full-paragraph-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:6px}.full-paragraph-label{font-size:11.5px;font-weight:780;color:#42485b}.full-paragraph-state{font-size:10px;color:#8f96a6}.full-paragraph.active .full-paragraph-state{color:var(--brand);font-weight:750}.full-paragraph-copy{font:13.5px/1.72 Georgia,"Times New Roman",serif;color:#4a5060;margin:0}.full-paragraph-foot{display:flex;justify-content:flex-end;margin-top:7px}.full-issues-button{border:0;background:transparent;color:#6663d9;font-size:10.5px;font-weight:750;padding:2px 0}.full-issues-button:hover{text-decoration:underline}
|
||||||
|
|
||||||
.rewrite-card{min-height:690px}.rewrite-intro{font-size:12px;color:#737b8d;margin-bottom:12px}.rewrite-area{width:100%;min-height:500px;border:1px solid #e2e4ed;border-radius:11px;outline:0;resize:vertical;padding:15px 16px;font:15px/1.8 Georgia,"Times New Roman",serif;color:#2f3443;background:#fff;transition:.15s}.rewrite-area:focus{border-color:#b9baff;box-shadow:0 0 0 3px rgba(86,84,245,.08)}.rewrite-meta{display:flex;justify-content:space-between;gap:10px;margin-top:8px;font-size:11px;color:#9aa0ad}.rewrite-help{margin-top:16px;border-top:1px solid var(--line);padding-top:13px}.help-entry{display:flex;align-items:center;gap:8px;font-size:11.5px;color:#777e8f}.help-entry .btn.link{font-size:11.5px;font-weight:760;padding:0}.help-panel{display:none;margin-top:10px;background:#fafbff;border:1px solid #e6e7f3;border-radius:10px;padding:11px 12px}.help-panel.open{display:block}.help-label{font-size:10px;color:#8b92a3;font-weight:800;margin-bottom:5px}.scaffold{font-size:12px;color:#50586a;line-height:1.7;white-space:pre-wrap}.reference-trigger{margin-top:9px;padding-top:8px;border-top:1px solid #eceef4}.reference{display:none;margin-top:8px;background:#fff;border:1px solid #e6e7ef;border-radius:8px;padding:9px 10px;font-size:11.5px;color:#596174;white-space:pre-wrap;line-height:1.7}.reference.open{display:block}.reference-note{font-size:10px;color:#9198a7;margin-top:6px}.write-tools{border-top:1px solid var(--line);padding:11px 14px;display:flex;justify-content:space-between;gap:10px;align-items:center}.nav-group{display:flex;gap:8px}.demo-hint{font-size:11px;color:#9a9fac}
|
.rewrite-card{min-height:0;display:flex;flex-direction:column}.rewrite-card .pane-head,.rewrite-card .write-tools{flex-shrink:0}.rewrite-card .pane-body{display:flex;flex-direction:column;flex:1;min-height:0;overflow-y:auto}.rewrite-intro{font-size:12px;color:#737b8d;margin-bottom:12px}.rewrite-area{width:100%;min-height:200px;border:1px solid #e2e4ed;border-radius:11px;outline:0;resize:none;padding:15px 16px;font:15px/1.8 Georgia,"Times New Roman",serif;color:#2f3443;background:#fff;transition:.15s}.rewrite-card .rewrite-area{flex:1}.rewrite-area:focus{border-color:#b9baff;box-shadow:0 0 0 3px rgba(86,84,245,.08)}.rewrite-meta{display:flex;justify-content:space-between;gap:10px;margin-top:8px;font-size:11px;color:#9aa0ad}.word-meta.warn{color:#b45309;font-weight:700}.translate-row{margin-top:10px;display:flex;align-items:center;gap:8px}.translate-row .btn.link{font-size:11.5px;font-weight:760;padding:0}.orig-translate-row{margin-top:9px;padding-top:8px;border-top:1px dashed #e8eaf3}.orig-translate-row .btn.link{font-size:11.5px;font-weight:760;padding:0}.orig-translate-box{margin-top:8px;background:#fafbff;border:1px solid #e8eaf6;border-radius:10px;padding:9px 10px}.ot-line{font-size:12.5px;line-height:1.75;color:#4c5468;padding:6px 9px;border-left:3px solid transparent;border-radius:6px;margin-bottom:5px;background:#fff}.ot-line:last-child{margin-bottom:0}.ot-line[data-note-id]{cursor:pointer}.ot-line sup{font-family:Inter,"PingFang SC",sans-serif;font-size:8px;font-weight:800;color:var(--note-strong,#514ee1);margin-right:3px}.ot-line.note-rhetoric,.ot-line.note-repeat,.ot-line.note-growth,.ot-line.note-structure{border-left-color:var(--note-line);background:var(--note-soft)}.ot-line.note-rhetoric.active,.ot-line.note-repeat.active,.ot-line.note-growth.active,.ot-line.note-structure.active{background:var(--note-bg);box-shadow:0 0 0 2px color-mix(in srgb,var(--note-line) 28%,transparent)}.translate-box{display:none;margin-top:8px;background:#f6f7ff;border:1px dashed #d9dcf5;border-radius:10px;padding:11px 12px;font-size:12px;color:#4c5468;line-height:1.75;white-space:pre-wrap}.translate-box.open{display:block}.translate-label{font-size:10px;color:#8b92a3;font-weight:800;margin-bottom:5px;display:block}.rewrite-help{margin-top:16px;border-top:1px solid var(--line);padding-top:13px}.help-entry{display:flex;align-items:center;gap:8px;font-size:11.5px;color:#777e8f}.help-entry .btn.link{font-size:11.5px;font-weight:760;padding:0}.help-panel{display:none;margin-top:10px;background:#fafbff;border:1px solid #e6e7f3;border-radius:10px;padding:11px 12px}.help-panel.open{display:block}.help-label{font-size:10px;color:#8b92a3;font-weight:800;margin-bottom:5px}.scaffold{font-size:12px;color:#50586a;line-height:1.7;white-space:pre-wrap}.reference-trigger{margin-top:9px;padding-top:8px;border-top:1px solid #eceef4}.reference{display:none;margin-top:8px;background:#fff;border:1px solid #e6e7ef;border-radius:8px;padding:9px 10px;font-size:11.5px;color:#596174;white-space:pre-wrap;line-height:1.7}.reference.open{display:block}.reference-note{font-size:10px;color:#9198a7;margin-top:6px}.write-tools{border-top:1px solid var(--line);padding:11px 14px;display:flex;justify-content:space-between;gap:10px;align-items:center}.nav-group{display:flex;gap:8px}.demo-hint{font-size:11px;color:#9a9fac}
|
||||||
.submit-row{position:fixed;left:0;right:0;bottom:0;background:linear-gradient(180deg,rgba(246,247,251,0),#f6f7fb 35%);padding:28px 24px 16px;display:flex;justify-content:flex-end;z-index:25;pointer-events:none}.submit-row .submit-inner{width:100%;max-width:1540px;margin:0 auto;display:flex;justify-content:flex-end;align-items:center;gap:12px;pointer-events:auto}.submit-hint{font-size:12px;color:#8a6270}.submit-row .btn{padding:11px 18px}
|
.submit-row{flex-shrink:0;padding:0 24px 16px;display:flex;justify-content:flex-end}.submit-row .submit-inner{width:100%;max-width:1540px;margin:0 auto;display:flex;justify-content:flex-end;align-items:center;gap:12px;pointer-events:auto}.submit-hint{font-size:12px;color:#8a6270}.submit-row .btn{padding:11px 18px}.recheck-history{position:fixed;right:24px;bottom:82px;width:min(440px,calc(100vw - 32px));max-height:54vh;overflow-y:auto;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:0 16px 40px rgba(28,31,45,.18);padding:12px 13px;z-index:30;display:none}.recheck-history.open{display:block}.rhi-title{font-size:12px;font-weight:800;color:#3a4053;margin-bottom:8px}.recheck-history-item{border:1px solid #ecedf4;border-radius:10px;padding:9px 10px;margin-bottom:8px}.recheck-history-item:last-child{margin-bottom:0}.rhi-head{display:flex;align-items:center;gap:7px;font-size:11px;color:#666e80}.rhi-badge{font-size:10.5px;padding:2px 7px;border-radius:999px;font-weight:800}.rhi-badge.ok{background:var(--good-soft);color:#167655}.rhi-badge.warn{background:var(--warn-soft);color:#8a5d10}.rhi-latest{font-size:10px;color:var(--brand);font-weight:800}.rhi-meta{font-size:10.5px;color:#9aa0ad;margin-top:4px}.rhi-goal{font-size:11.5px;color:#4c5468;line-height:1.65;margin-top:5px}.rhi-revision{margin-top:7px}
|
||||||
.demo-panel{position:fixed;left:14px;bottom:14px;z-index:60}.demo-panel details{background:#fff;border:1px solid #dfe2ea;border-radius:10px;box-shadow:0 7px 20px rgba(23,27,42,.08);font-size:11px;color:#7d8495}.demo-panel summary{cursor:pointer;padding:7px 10px;font-weight:700;color:#666e80}.demo-panel .demo-body{padding:0 9px 9px;display:flex;gap:6px;flex-wrap:wrap;max-width:260px}
|
.demo-panel{position:fixed;left:14px;bottom:14px;z-index:60}.demo-panel details{background:#fff;border:1px solid #dfe2ea;border-radius:10px;box-shadow:0 7px 20px rgba(23,27,42,.08);font-size:11px;color:#7d8495}.demo-panel summary{cursor:pointer;padding:7px 10px;font-weight:700;color:#666e80}.demo-panel .demo-body{padding:0 9px 9px;display:flex;gap:6px;flex-wrap:wrap;max-width:260px}
|
||||||
.loading-card{padding:20px 22px}.loading-title{font-size:16px;font-weight:760;margin-bottom:10px}.loading-row{display:flex;align-items:center;gap:9px;padding:7px 0;color:#687084;font-size:12px}.spinner{width:17px;height:17px;border:2px solid #dfe1ff;border-top-color:var(--brand);border-radius:50%;animation:spin .8s linear infinite}.loading-dot{width:8px;height:8px;border-radius:50%;background:#d7dbe6}.loading-row.active{color:#4247c7;font-weight:650}.loading-row.active .loading-dot{background:var(--brand)}.loading-hint{color:#b9770b;font-size:12px;font-weight:600;margin-top:8px}@keyframes spin{to{transform:rotate(360deg)}}
|
.loading-card{padding:20px 22px}.loading-title{font-size:16px;font-weight:760;margin-bottom:10px}.loading-row{display:flex;align-items:center;gap:9px;padding:7px 0;color:#687084;font-size:12px}.spinner{width:17px;height:17px;border:2px solid #dfe1ff;border-top-color:var(--brand);border-radius:50%;animation:spin .8s linear infinite}.loading-dot{width:8px;height:8px;border-radius:50%;background:#d7dbe6}.loading-row.active{color:#4247c7;font-weight:650}.loading-row.active .loading-dot{background:var(--brand)}.loading-hint{color:#b9770b;font-size:12px;font-weight:600;margin-top:8px}@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
@media(max-width:1180px){.entry-shell{grid-template-columns:1fr 320px}.work-grid{gap:9px;padding-left:12px;padding-right:12px}.work-head{padding-left:14px;padding-right:14px}.pane-head{padding:0 12px}.progress-track{width:100px}.paragraph-switcher{max-width:min(280px,38vw)}}
|
@media(max-width:1180px){.entry-shell{grid-template-columns:1fr 320px}.work-grid{gap:9px;padding-left:12px;padding-right:12px}.work-head{padding-left:14px;padding-right:14px}.pane-head{padding:0 12px}.progress-track{width:100px}.paragraph-switcher{max-width:min(280px,38vw)}}
|
||||||
@media(max-width:900px){.work-grid{grid-template-columns:1fr}.sticky-pane{position:relative;top:auto;max-height:none}.pane-card{min-height:auto}.rewrite-area{min-height:360px}.submit-row{position:relative;padding:0 12px 20px}.work-head-inner{align-items:flex-start}.progress-mini{min-width:0}.demo-panel{display:none}}
|
@media(max-width:900px){.workbench-shell{height:auto;display:block;overflow:visible}.work-grid{grid-template-columns:1fr;align-items:start;overflow:visible}.sticky-pane{height:auto;display:block;overflow:visible}.sticky-pane>.pane-body{overflow:visible}.pane-card{min-height:auto}.rewrite-area{min-height:360px}.rewrite-card .rewrite-area{flex:none}.submit-row{padding:0 12px 20px}.work-head-inner{align-items:flex-start}.progress-mini{min-width:0}.demo-panel{display:none}}
|
||||||
@media(max-width:760px){.entry-shell{display:block;height:auto}.essay-pane{padding:24px 18px}.smart-pane{border-top:1px solid var(--line)}.smart-body{padding:24px 18px}.top-actions{display:none}.chat-wrap{padding:20px 12px 120px}.message{grid-template-columns:30px 1fr;gap:8px}.avatar{width:30px;height:30px}.bubble{padding:16px}.user .bubble{max-width:90%}.agent-table{font-size:11px}.work-grid{padding:12px}.work-head{padding:12px}.work-head-inner{display:block}.progress-mini{margin-top:8px;justify-content:flex-start}.progress-track{width:120px}.original-pane-head{height:auto;min-height:58px;flex-wrap:wrap;padding-top:9px;padding-bottom:9px}.paragraph-switcher{margin-left:0;max-width:100%}.pane-body{padding:14px}}
|
@media(max-width:760px){.entry-shell{display:block;height:auto}.essay-pane{padding:24px 18px}.smart-pane{border-top:1px solid var(--line)}.smart-body{padding:24px 18px}.top-actions{display:none}.chat-wrap{padding:20px 12px 120px}.message{grid-template-columns:30px 1fr;gap:8px}.avatar{width:30px;height:30px}.bubble{padding:16px}.user .bubble{max-width:90%}.agent-table{font-size:11px}.work-grid{padding:12px}.work-head{padding:12px}.work-head-inner{display:block}.progress-mini{margin-top:8px;justify-content:flex-start}.progress-track{width:120px}.original-pane-head{height:auto;min-height:54px;flex-wrap:wrap;padding-top:9px;padding-bottom:9px}.paragraph-switcher{margin-left:0;max-width:100%}.pane-body{padding:14px}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -192,7 +206,9 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
|
|||||||
<div class="pane-body">
|
<div class="pane-body">
|
||||||
<div class="rewrite-intro">基于左侧原意重新表达这一整段,不需要逐句对应原文。</div>
|
<div class="rewrite-intro">基于左侧原意重新表达这一整段,不需要逐句对应原文。</div>
|
||||||
<textarea id="rewriteArea" class="rewrite-area" placeholder="在这里重写当前段……"></textarea>
|
<textarea id="rewriteArea" class="rewrite-area" placeholder="在这里重写当前段……"></textarea>
|
||||||
<div class="rewrite-meta"><span id="saveMeta">自动保存</span><span id="charCount">0 characters</span></div>
|
<div class="rewrite-meta"><span id="saveMeta">自动保存</span><span id="wordMeta" class="word-meta"></span><span id="charCount">0 characters</span></div>
|
||||||
|
<div class="translate-row"><button id="copyOriginalBtn" class="btn link">复制原文到改写区 →</button><button id="translateBtn" class="btn link">译成中文对照 →</button></div>
|
||||||
|
<div id="translateBox" class="translate-box"></div>
|
||||||
<div class="rewrite-help">
|
<div class="rewrite-help">
|
||||||
<div class="help-entry"><span>需要一点帮助?</span><button id="showScaffold" class="btn link">给我一个写作起点 →</button></div>
|
<div class="help-entry"><span>需要一点帮助?</span><button id="showScaffold" class="btn link">给我一个写作起点 →</button></div>
|
||||||
<div id="scaffoldPanel" class="help-panel">
|
<div id="scaffoldPanel" class="help-panel">
|
||||||
@@ -210,9 +226,12 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div class="submit-row"><div class="submit-inner"><span id="submitHint" class="submit-hint hidden"></span><button id="submitRecheck" class="btn primary" disabled>完成全文改写,提交复检 →</button></div></div>
|
<div class="submit-row"><div class="submit-inner"><span id="submitHint" class="submit-hint hidden"></span><button id="recheckHistoryBtn" class="btn small hidden">复检历史 (0)</button><button id="submitRecheck" class="btn primary" disabled>完成全文改写,提交复检 →</button></div></div>
|
||||||
<div class="demo-panel"><details><summary>Demo 工具</summary><div class="demo-body"><button id="mockFillCurrent" class="btn small">填入当前段</button><button id="mockFillAll" class="btn small">填入模拟全文</button><button id="mockFillFail" class="btn small">填入返工案例</button></div></details></div>
|
<div class="demo-panel"><details><summary>Demo 工具</summary><div class="demo-body"><button id="mockFillCurrent" class="btn small">填入当前段</button><button id="mockFillAll" class="btn small">填入模拟全文</button><button id="mockFillFail" class="btn small">填入返工案例</button></div></details></div>
|
||||||
</section>
|
</section>
|
||||||
|
<!-- 复检历史面板:必须挂在视图容器外 —— 放在 workbenchView 内时,
|
||||||
|
对话页(workbenchView display:none)点击「查看复检历史」面板不渲染(用户实测) -->
|
||||||
|
<div id="recheckHistoryPanel" class="recheck-history" aria-label="复检历史记录"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -253,8 +272,23 @@ let scaffoldsShown = [];
|
|||||||
let currentRewriteVersion = 1;
|
let currentRewriteVersion = 1;
|
||||||
let lastRecheckSnapshot = null;
|
let lastRecheckSnapshot = null;
|
||||||
let lastRecheckResult = null;
|
let lastRecheckResult = null;
|
||||||
|
// 体验优化:复检汇总建议历史(最新在前,上限 10 条)——支持回看历次结论 +
|
||||||
|
// 按历史建议再次进入改写(结合该条建议修改)。
|
||||||
|
let recheckHistory = [];
|
||||||
|
// 体验优化:改写稿中文对照翻译缓存(按段下标)。rec.text 与当前改写稿不一致
|
||||||
|
// 时视为过期,需重新翻译(保证译文对应当前版本)。
|
||||||
|
let translations = [];
|
||||||
|
let translateOpen = [];
|
||||||
|
// 体验优化:原文中文对照(逐句)缓存(按段下标)。rec.sentences = [{en,zh}],
|
||||||
|
// 句子由前端拆分(en 是原文精确文本),命中批注 evidence 的句子在对照里
|
||||||
|
// 与原文同色 + 同序号,让「需要改写的那句」能直接找到中文。
|
||||||
|
let origTranslations = [];
|
||||||
|
let origTranslateOpen = [];
|
||||||
let analysisVersion = 1;
|
let analysisVersion = 1;
|
||||||
let helpState = {};
|
let helpState = {};
|
||||||
|
// 「查看之前的写作建议」(<details>) 展开状态:翻译/切换等会重建左栏 DOM,
|
||||||
|
// 不记住的话翻译完成后会意外收起(用户实测)。
|
||||||
|
let priorGuidanceOpen = false;
|
||||||
let dirtyDuringRecheck = false;
|
let dirtyDuringRecheck = false;
|
||||||
let transitioning = false;
|
let transitioning = false;
|
||||||
// 对话历史(快照)持久化:刷新后回对话页不丢理解/诊断/复检卡片(用户实测发现 + PM 同意修复)。
|
// 对话历史(快照)持久化:刷新后回对话页不丢理解/诊断/复检卡片(用户实测发现 + PM 同意修复)。
|
||||||
@@ -367,6 +401,14 @@ function ensureParaState(){
|
|||||||
rewriteStatus = rewriteStatus.slice(0,n);
|
rewriteStatus = rewriteStatus.slice(0,n);
|
||||||
saveStatusByPara = saveStatusByPara.slice(0,n);
|
saveStatusByPara = saveStatusByPara.slice(0,n);
|
||||||
saveTimers = saveTimers.slice(0,n);
|
saveTimers = saveTimers.slice(0,n);
|
||||||
|
while(translations.length<n) translations.push(null);
|
||||||
|
while(translateOpen.length<n) translateOpen.push(false);
|
||||||
|
translations = translations.slice(0,n);
|
||||||
|
translateOpen = translateOpen.slice(0,n);
|
||||||
|
while(origTranslations.length<n) origTranslations.push(null);
|
||||||
|
while(origTranslateOpen.length<n) origTranslateOpen.push(false);
|
||||||
|
origTranslations = origTranslations.slice(0,n);
|
||||||
|
origTranslateOpen = origTranslateOpen.slice(0,n);
|
||||||
}
|
}
|
||||||
function briefFor(pid){return (diagnosis&&diagnosis.paragraph_briefs||[]).find(b=>b.paragraph_id===pid)||null;}
|
function briefFor(pid){return (diagnosis&&diagnosis.paragraph_briefs||[]).find(b=>b.paragraph_id===pid)||null;}
|
||||||
function patternsFor(pid){return (diagnosis&&diagnosis.patterns||[]).filter(p=>(p.affected_paragraphs||[]).includes(pid));}
|
function patternsFor(pid){return (diagnosis&&diagnosis.patterns||[]).filter(p=>(p.affected_paragraphs||[]).includes(pid));}
|
||||||
@@ -447,8 +489,9 @@ function applyEditedEssay(){
|
|||||||
const wl = parseInt($('#wordLimitInput').value, 10);
|
const wl = parseInt($('#wordLimitInput').value, 10);
|
||||||
wordLimit = Number.isFinite(wl) && wl > 0 ? wl : null;
|
wordLimit = Number.isFinite(wl) && wl > 0 ? wl : null;
|
||||||
paragraphs = splitParagraphs(essayText);
|
paragraphs = splitParagraphs(essayText);
|
||||||
// 文书内容变化后,旧版本(改写后全文)失去参照,清空版本切换
|
// 文书内容变化后,旧版本(改写后全文)失去参照,清空版本切换与复检历史/译文
|
||||||
essayVersions = []; viewedVersion = null;
|
essayVersions = []; viewedVersion = null;
|
||||||
|
recheckHistory = []; translations = []; translateOpen = []; origTranslations = []; origTranslateOpen = [];
|
||||||
ensureParaState();
|
ensureParaState();
|
||||||
closeEditor();
|
closeEditor();
|
||||||
renderEntryEssay();
|
renderEntryEssay();
|
||||||
@@ -460,6 +503,7 @@ function loadSample(){
|
|||||||
wordLimit = 650;
|
wordLimit = 650;
|
||||||
paragraphs = SAMPLE_ESSAY.slice();
|
paragraphs = SAMPLE_ESSAY.slice();
|
||||||
essayVersions = []; viewedVersion = null;
|
essayVersions = []; viewedVersion = null;
|
||||||
|
recheckHistory = []; translations = []; translateOpen = []; origTranslations = []; origTranslateOpen = [];
|
||||||
ensureParaState();
|
ensureParaState();
|
||||||
closeEditor();
|
closeEditor();
|
||||||
renderEntryEssay();
|
renderEntryEssay();
|
||||||
@@ -474,7 +518,8 @@ function persistNow(){
|
|||||||
current, originalView, revisionMode, revisionTarget,
|
current, originalView, revisionMode, revisionTarget,
|
||||||
referencesShown, scaffoldsShown, currentRewriteVersion,
|
referencesShown, scaffoldsShown, currentRewriteVersion,
|
||||||
lastRecheckSnapshot, lastRecheckResult, analysisVersion, agentStage,
|
lastRecheckSnapshot, lastRecheckResult, analysisVersion, agentStage,
|
||||||
chatLog, essayVersions, viewedVersion
|
chatLog, essayVersions, viewedVersion, recheckHistory, translations, translateOpen,
|
||||||
|
origTranslations, origTranslateOpen, chatSessionId
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
}catch(e){ return false; }
|
}catch(e){ return false; }
|
||||||
@@ -528,9 +573,17 @@ 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;
|
||||||
|
recheckHistory = Array.isArray(s.recheckHistory) ? s.recheckHistory.slice(0,10) : [];
|
||||||
|
translations = Array.isArray(s.translations) ? s.translations : [];
|
||||||
|
translateOpen = Array.isArray(s.translateOpen) ? s.translateOpen : [];
|
||||||
|
origTranslations = Array.isArray(s.origTranslations) ? s.origTranslations : [];
|
||||||
|
origTranslateOpen = Array.isArray(s.origTranslateOpen) ? s.origTranslateOpen : [];
|
||||||
ensureParaState();
|
ensureParaState();
|
||||||
if(essayText !== SAMPLE_ESSAY.join('\n\n')) openEditor();
|
if(essayText !== SAMPLE_ESSAY.join('\n\n')) openEditor();
|
||||||
else renderEntryEssay();
|
else renderEntryEssay();
|
||||||
@@ -558,6 +611,7 @@ function restoreSession(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreChatHistory(){
|
function restoreChatHistory(){
|
||||||
|
renderRecheckHistory();
|
||||||
if(!chatLog || !chatLog.length) return;
|
if(!chatLog || !chatLog.length) return;
|
||||||
chat.innerHTML = '';
|
chat.innerHTML = '';
|
||||||
// silent 重放:只建 DOM 不重复入日志;按钮由 bindQuickActions 统一重绑,
|
// silent 重放:只建 DOM 不重复入日志;按钮由 bindQuickActions 统一重绑,
|
||||||
@@ -609,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(){
|
||||||
@@ -644,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;
|
||||||
@@ -713,11 +731,18 @@ function disableCompletedActions(){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function handleAction(action){
|
function handleAction(action){
|
||||||
|
// 复检卡上的「查看复检历史」:两处(通过卡/返工卡)共用,面板 fixed 定位,
|
||||||
|
// 对话页与 Workbench 都能显示。
|
||||||
|
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 会把其它按钮的提示残留下来——用户实测):
|
||||||
@@ -745,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);
|
||||||
@@ -786,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){
|
||||||
@@ -885,8 +1105,9 @@ function renderAnnotatedText(text, annotations){
|
|||||||
}
|
}
|
||||||
function bindAnnotationInteractions(wrap){
|
function bindAnnotationInteractions(wrap){
|
||||||
const activate = id => {
|
const activate = id => {
|
||||||
wrap.querySelectorAll('.annotation-anchor,.annotation-card').forEach(el=>el.classList.remove('active'));
|
wrap.querySelectorAll('.annotation-anchor,.annotation-card,.ot-line').forEach(el=>el.classList.remove('active'));
|
||||||
wrap.querySelectorAll(`.annotation-anchor[data-note-id="${id}"]`).forEach(el=>el.classList.add('active'));
|
wrap.querySelectorAll(`.annotation-anchor[data-note-id="${id}"]`).forEach(el=>el.classList.add('active'));
|
||||||
|
wrap.querySelectorAll(`.ot-line[data-note-id="${id}"]`).forEach(el=>el.classList.add('active'));
|
||||||
const card = wrap.querySelector(`.annotation-card[data-note-id="${id}"]`);
|
const card = wrap.querySelector(`.annotation-card[data-note-id="${id}"]`);
|
||||||
if(card){ card.classList.add('active'); card.scrollIntoView({block:'nearest',behavior:'smooth'}); }
|
if(card){ card.classList.add('active'); card.scrollIntoView({block:'nearest',behavior:'smooth'}); }
|
||||||
track('annotation_anchor_opened', {paragraph_id: pidOf(current), annotation_id: id});
|
track('annotation_anchor_opened', {paragraph_id: pidOf(current), annotation_id: id});
|
||||||
@@ -896,6 +1117,12 @@ function bindAnnotationInteractions(wrap){
|
|||||||
el.onkeydown = e => { if(e.key==='Enter'||e.key===' '){ e.preventDefault(); activate(el.dataset.noteId); } };
|
el.onkeydown = e => { if(e.key==='Enter'||e.key===' '){ e.preventDefault(); activate(el.dataset.noteId); } };
|
||||||
});
|
});
|
||||||
wrap.querySelectorAll('.annotation-card').forEach(el=>el.onclick=e=>{ if(!e.target.closest('.why-toggle')) activate(el.dataset.noteId); });
|
wrap.querySelectorAll('.annotation-card').forEach(el=>el.onclick=e=>{ if(!e.target.closest('.why-toggle')) activate(el.dataset.noteId); });
|
||||||
|
// 原文中文对照(逐句):对照行与批注同色同序号 —— 点击对照行可联动高亮原文句
|
||||||
|
wrap.querySelectorAll('[data-role="orig-translate"]').forEach(btn=>{ btn.onclick = toggleOrigTranslate; });
|
||||||
|
// 记住「查看之前的写作建议」的展开状态(左栏重建后恢复,避免翻译完成被收起)
|
||||||
|
const prior = wrap.querySelector('.prior-guidance');
|
||||||
|
if(prior) prior.addEventListener('toggle', ()=>{ priorGuidanceOpen = prior.open; });
|
||||||
|
wrap.querySelectorAll('.ot-line[data-note-id]').forEach(el=>{ el.onclick = () => activate(el.dataset.noteId); });
|
||||||
wrap.querySelectorAll('.why-toggle').forEach(btn=>btn.onclick=e=>{
|
wrap.querySelectorAll('.why-toggle').forEach(btn=>btn.onclick=e=>{
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const card = btn.closest('.annotation-card');
|
const card = btn.closest('.annotation-card');
|
||||||
@@ -933,10 +1160,15 @@ function renderFocusOriginal(wrap){
|
|||||||
<div class="direction-line action"><span class="direction-chip action">重写要做什么</span><div class="direction-text">${escapeHtml(fusedGoal(current))}</div></div>
|
<div class="direction-line action"><span class="direction-chip action">重写要做什么</span><div class="direction-text">${escapeHtml(fusedGoal(current))}</div></div>
|
||||||
<div class="direction-line ai"><span class="direction-chip ai">AI味要注意</span><div class="direction-text">${escapeHtml(fusedAiFocus(current))}</div></div>
|
<div class="direction-line ai"><span class="direction-chip ai">AI味要注意</span><div class="direction-text">${escapeHtml(fusedAiFocus(current))}</div></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
const otRec = origTranslations[current];
|
||||||
|
const otFresh = otRec && otRec.text === String(paragraphs[current]||'').trim();
|
||||||
|
const otOpen = !!(otFresh && origTranslateOpen[current]);
|
||||||
const paper = `
|
const paper = `
|
||||||
<div class="annotated-paper">
|
<div class="annotated-paper">
|
||||||
<div class="paper-title"><strong>原文</strong><span>${notes.length} 类重写提醒 · 颜色区分问题类型</span></div>
|
<div class="paper-title"><strong>原文</strong><span>${notes.length} 类重写提醒 · 颜色区分问题类型</span></div>
|
||||||
<p class="annotated-copy">${renderAnnotatedText(paragraphs[current]||'', notes)}</p>
|
<p class="annotated-copy">${renderAnnotatedText(paragraphs[current]||'', notes)}</p>
|
||||||
|
<div class="orig-translate-row"><button class="btn link" data-role="orig-translate">${otOpen ? '收起中文对照 ↑' : otFresh ? '查看原文中文对照 →' : '原文中文对照 →'}</button></div>
|
||||||
|
${otOpen ? `<div class="orig-translate-box">${origTranslateLinesHtml(otRec)}</div>` : ''}
|
||||||
<div class="annotation-list">${annotationCardsHtml(notes)}</div>
|
<div class="annotation-list">${annotationCardsHtml(notes)}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
const visibleMeaning = `<div class="rewrite-direction">
|
const visibleMeaning = `<div class="rewrite-direction">
|
||||||
@@ -944,7 +1176,7 @@ function renderFocusOriginal(wrap){
|
|||||||
<div class="direction-line meaning"><span class="direction-chip meaning">这段在讲什么</span><div class="direction-text">${escapeHtml(meaningOf(current))}</div></div>
|
<div class="direction-line meaning"><span class="direction-chip meaning">这段在讲什么</span><div class="direction-text">${escapeHtml(meaningOf(current))}</div></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
const body = isRevisionTarget(current)
|
const body = isRevisionTarget(current)
|
||||||
? `${visibleMeaning}<details class="prior-guidance"><summary>查看之前的写作建议</summary>${fullDirection}${paper}</details>`
|
? `${visibleMeaning}<details class="prior-guidance"${priorGuidanceOpen ? ' open' : ''}><summary>查看之前的写作建议</summary>${fullDirection}${paper}</details>`
|
||||||
: fullDirection + paper;
|
: fullDirection + paper;
|
||||||
wrap.className = 'original-workspace';
|
wrap.className = 'original-workspace';
|
||||||
wrap.innerHTML = `${banner}${body}<div class="context-hint"><div class="context-label">和上下文怎么接</div><div class="context-copy">${escapeHtml(contextHintOf(current))}</div></div>`;
|
wrap.innerHTML = `${banner}${body}<div class="context-hint"><div class="context-label">和上下文怎么接</div><div class="context-copy">${escapeHtml(contextHintOf(current))}</div></div>`;
|
||||||
@@ -978,16 +1210,40 @@ function renderParagraphSwitcher(){
|
|||||||
$('#focusOriginal').classList.toggle('active', originalView==='focus');
|
$('#focusOriginal').classList.toggle('active', originalView==='focus');
|
||||||
$('#fullOriginal').classList.toggle('active', originalView==='full');
|
$('#fullOriginal').classList.toggle('active', originalView==='full');
|
||||||
}
|
}
|
||||||
function renderOriginalPane(){
|
// 轻量更新段号按钮状态(active / has-copy),不重建 DOM:
|
||||||
|
// 改写区 blur(点段号的 mousedown 触发)若重建按钮,mouseup 时按钮已被替换,
|
||||||
|
// 第一次点击会丢失(用户实测:改完字点段号要连点两次)。
|
||||||
|
function updateParagraphSwitcherState(){
|
||||||
|
const wrap = $('#paragraphSwitcher');
|
||||||
|
if(!wrap) return;
|
||||||
|
wrap.querySelectorAll('[data-para-index]').forEach(btn=>{
|
||||||
|
const i = Number(btn.dataset.paraIndex);
|
||||||
|
btn.classList.toggle('active', i===current);
|
||||||
|
btn.classList.toggle('has-copy', hasRewrite(i));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// keepScroll=true 时保留当前滚动位置(翻译/对照展开这类内容更新);
|
||||||
|
// 默认回到顶部 —— 切段/首次渲染/切视图若沿用旧位置,会停在上次滚到的
|
||||||
|
// 位置(浏览器刷新后也会自动恢复内层滚动容器的位置,表现为"一打开就在
|
||||||
|
// 最下面",用户实测)。
|
||||||
|
function renderOriginalPane(keepScroll){
|
||||||
const wrap=$('#originalFull');
|
const wrap=$('#originalFull');
|
||||||
|
const body = wrap.closest('.pane-body');
|
||||||
|
const scrollTop = keepScroll && body ? body.scrollTop : 0;
|
||||||
wrap.innerHTML='';
|
wrap.innerHTML='';
|
||||||
if(originalView==='focus') renderFocusOriginal(wrap); else renderFullOriginal(wrap);
|
if(originalView==='focus') renderFocusOriginal(wrap); else renderFullOriginal(wrap);
|
||||||
renderParagraphSwitcher();
|
renderParagraphSwitcher();
|
||||||
|
if(body) body.scrollTop = scrollTop;
|
||||||
}
|
}
|
||||||
function updateProgress(){
|
function updateProgress(){
|
||||||
const n = rewrittenCount();
|
const n = rewrittenCount();
|
||||||
const total = paragraphs.length;
|
const total = paragraphs.length;
|
||||||
$('#progressText').textContent = `已有改写 ${n} 段 · 共 ${total} 段`;
|
// 体验优化:全文词数提示(未改写段按原文计)——对照 word_limit 控制总长度;
|
||||||
|
// 超出题目字数限制时标红(复检的 word_limit 检查项同源)。
|
||||||
|
const words = essayWordTotal();
|
||||||
|
const pt = $('#progressText');
|
||||||
|
pt.textContent = `已有改写 ${n} 段 · 共 ${total} 段 · 全文 ${words}${wordLimit?` / ${wordLimit}`:''} 词`;
|
||||||
|
pt.classList.toggle('over', !!(wordLimit && words>wordLimit));
|
||||||
$('#progressBar').style.width = `${total? n/total*100 : 0}%`;
|
$('#progressBar').style.width = `${total? n/total*100 : 0}%`;
|
||||||
// PRD §29.1:复检通过 = 本轮结束(状态流转"通过 → 结束",用户实测"通过后
|
// PRD §29.1:复检通过 = 本轮结束(状态流转"通过 → 结束",用户实测"通过后
|
||||||
// 仍可再提交复检")。通过后不再提供提交复检入口——再次复检会以
|
// 仍可再提交复检")。通过后不再提供提交复检入口——再次复检会以
|
||||||
@@ -1025,6 +1281,142 @@ function renderSaveChip(){
|
|||||||
chip.className='chip good';
|
chip.className='chip good';
|
||||||
chip.onclick=null;
|
chip.onclick=null;
|
||||||
}
|
}
|
||||||
|
// 体验优化:每段改写字数控制提示 —— 原文段词数 vs 当前改写词数;偏差过大
|
||||||
|
// 时黄色提醒(改写应基于原意重新表达,长度通常接近原文;防止信息丢失或膨胀)。
|
||||||
|
// 全文词数在 updateProgress 里连同 word_limit 一起提示。
|
||||||
|
function renderWordMeta(){
|
||||||
|
const el = $('#wordMeta'); if(!el) return;
|
||||||
|
const orig = wordCount(paragraphs[current]||'');
|
||||||
|
const cur = wordCount(rewrites[current]||'');
|
||||||
|
let text = `原文 ${orig} 词 · 当前 ${cur} 词`;
|
||||||
|
let warn = false;
|
||||||
|
if(cur>0 && orig>0){
|
||||||
|
const ratio = cur/orig;
|
||||||
|
if(ratio>1.4 || ratio<0.6){ warn = true; text += ' · 与原文长度相差较大'; }
|
||||||
|
}
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.toggle('warn', warn);
|
||||||
|
}
|
||||||
|
function essayWordTotal(){
|
||||||
|
return paragraphs.reduce((n,p,i)=>n+wordCount(hasRewrite(i)?rewrites[i]:p),0);
|
||||||
|
}
|
||||||
|
// 体验优化:改写稿中文对照翻译 —— 顾问自查「改写后原意/细节是否保留」。
|
||||||
|
// 译文缓存按段保存,改写稿变化即过期(renderTranslateBox 里自动收起)。
|
||||||
|
function renderTranslateBtn(){
|
||||||
|
const btn = $('#translateBtn'); if(!btn) return;
|
||||||
|
const rec = translations[current];
|
||||||
|
const fresh = rec && rec.text === String(rewrites[current]||'').trim();
|
||||||
|
if(fresh && translateOpen[current]) btn.textContent = '收起译文 ↑';
|
||||||
|
else if(fresh) btn.textContent = '查看中文对照 →';
|
||||||
|
else btn.textContent = '译成中文对照 →';
|
||||||
|
}
|
||||||
|
function renderTranslateBox(){
|
||||||
|
const box = $('#translateBox'); if(!box) return;
|
||||||
|
const rec = translations[current];
|
||||||
|
const fresh = rec && rec.text === String(rewrites[current]||'').trim();
|
||||||
|
if(!fresh || !translateOpen[current]){ box.classList.remove('open'); box.innerHTML=''; return; }
|
||||||
|
box.innerHTML = `<span class="translate-label">中文对照(用于自查改写后原意是否保留)</span>${escapeHtml(rec.translation)}`;
|
||||||
|
box.classList.add('open');
|
||||||
|
}
|
||||||
|
async function toggleTranslate(){
|
||||||
|
const i = current;
|
||||||
|
const area = $('#rewriteArea');
|
||||||
|
if(area) rewrites[i] = area.value;
|
||||||
|
const text = String(rewrites[i]||'').trim();
|
||||||
|
const btn = $('#translateBtn');
|
||||||
|
const rec = translations[i];
|
||||||
|
const fresh = rec && rec.text === text;
|
||||||
|
if(fresh){
|
||||||
|
translateOpen[i] = !translateOpen[i];
|
||||||
|
persistNow(); renderTranslateBtn(); renderTranslateBox();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!text){
|
||||||
|
btn.textContent = '先写下你的改写,再翻译 ↗';
|
||||||
|
setTimeout(renderTranslateBtn, 1800);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = true; btn.textContent = '翻译中…';
|
||||||
|
try{
|
||||||
|
const resp = await postJSON('/api/translate',{text, paragraph_id: pidOf(i)});
|
||||||
|
translations[i] = {text, translation: resp.translation||''};
|
||||||
|
translateOpen[i] = true;
|
||||||
|
track('rewrite_translated', {paragraph_id: pidOf(i)});
|
||||||
|
persistNow();
|
||||||
|
}catch(e){
|
||||||
|
btn.disabled = false; btn.textContent = '翻译失败,点击重试';
|
||||||
|
setTimeout(renderTranslateBtn, 2200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
renderTranslateBtn(); renderTranslateBox();
|
||||||
|
}
|
||||||
|
// 体验优化:把当前段原文一键复制到改写区 —— 顾问以原文为起点手动改写
|
||||||
|
// (改写区已有内容时二次确认,避免覆盖已写的工作)。
|
||||||
|
function copyOriginalToRewrite(){
|
||||||
|
const area = $('#rewriteArea');
|
||||||
|
const text = String(paragraphs[current]||'').trim();
|
||||||
|
if(!area || !text) return;
|
||||||
|
if(String(area.value||'').trim() && !confirm('改写区已有内容,确定用原文覆盖吗?')) return;
|
||||||
|
area.value = text;
|
||||||
|
area.focus();
|
||||||
|
onRewriteInput({target: area}); // 复用输入处理:状态/字数提示/译文过期/保存调度
|
||||||
|
track('original_copied_to_rewrite', {paragraph_id: pidOf(current)});
|
||||||
|
}
|
||||||
|
// 体验优化:原文中文对照(逐句)—— 顾问读英文吃力时,特别是被高亮标注
|
||||||
|
// 「需要改写」的句子,需要它的中文意思才能判断怎么改。
|
||||||
|
// 句子由前端拆分(en 为原文精确文本,服务端原样回填),命中批注 evidence 的
|
||||||
|
// 句子在对照里与原文同色 + 同序号,可直接对上。
|
||||||
|
function splitSentences(text){
|
||||||
|
const raw = String(text||'').trim();
|
||||||
|
if(!raw) return [];
|
||||||
|
const parts = raw.match(/[^.!?]+(?:[.!?]+["'”’]?|$)/g) || [raw];
|
||||||
|
return parts.map(s=>s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
function noteHitForSentence(sentence, notes){
|
||||||
|
const norm = s=>String(s||'').toLowerCase().replace(/\s+/g,' ').trim();
|
||||||
|
const target = norm(sentence);
|
||||||
|
for(let i=0;i<notes.length;i++){
|
||||||
|
for(const ev of (notes[i].evidence||[])){
|
||||||
|
const needle = norm(ev);
|
||||||
|
if(needle && target.includes(needle)) return {index:i, category: notes[i].category||'rhetoric'};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function origTranslateLinesHtml(rec){
|
||||||
|
const notes = annotationsFor(current);
|
||||||
|
return (rec.sentences||[]).map(s=>{
|
||||||
|
const hit = noteHitForSentence(s.en, notes);
|
||||||
|
return `<div class="ot-line${hit?` note-${escapeHtml(hit.category)}`:''}"${hit?` data-note-id="${hit.index}"`:''}>${hit?`<sup>${hit.index+1}</sup>`:''}<span class="ot-zh">${escapeHtml(s.zh)}</span></div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
async function toggleOrigTranslate(){
|
||||||
|
const i = current;
|
||||||
|
const text = String(paragraphs[i]||'').trim();
|
||||||
|
const rec = origTranslations[i];
|
||||||
|
const fresh = rec && rec.text === text;
|
||||||
|
if(fresh){
|
||||||
|
origTranslateOpen[i] = !origTranslateOpen[i];
|
||||||
|
persistNow(); renderOriginalPane(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sentences = splitSentences(text);
|
||||||
|
if(!sentences.length) return;
|
||||||
|
const btn = document.querySelector('[data-role="orig-translate"]');
|
||||||
|
if(btn){ btn.disabled = true; btn.textContent = '翻译中…'; }
|
||||||
|
try{
|
||||||
|
const resp = await postJSON('/api/translate',{sentences, paragraph_id: pidOf(i)});
|
||||||
|
origTranslations[i] = {text, sentences: resp.sentences||[]};
|
||||||
|
origTranslateOpen[i] = true;
|
||||||
|
track('original_translated', {paragraph_id: pidOf(i), sentences: sentences.length});
|
||||||
|
persistNow();
|
||||||
|
}catch(e){
|
||||||
|
if(btn){ btn.disabled = false; btn.textContent = '翻译失败,点击重试'; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderOriginalPane(true);
|
||||||
|
}
|
||||||
function resetHelpPanels(){
|
function resetHelpPanels(){
|
||||||
const key = pidOf(current);
|
const key = pidOf(current);
|
||||||
const st = helpState[key] || {scaffold:false, reference:false};
|
const st = helpState[key] || {scaffold:false, reference:false};
|
||||||
@@ -1046,8 +1438,18 @@ function renderWorkbench(){
|
|||||||
$('#nextPara').disabled = current===paragraphs.length-1;
|
$('#nextPara').disabled = current===paragraphs.length-1;
|
||||||
renderOriginalPane();
|
renderOriginalPane();
|
||||||
renderSaveChip();
|
renderSaveChip();
|
||||||
|
renderWordMeta();
|
||||||
|
renderTranslateBtn();
|
||||||
|
renderTranslateBox();
|
||||||
resetHelpPanels();
|
resetHelpPanels();
|
||||||
updateProgress();
|
updateProgress();
|
||||||
|
renderRecheckHistory();
|
||||||
|
// 首次渲染后再固定一次顶部:浏览器刷新会恢复内层滚动容器位置,
|
||||||
|
// 其恢复时机可能晚于本次渲染(用户实测「一打开就在最下面」)。
|
||||||
|
if(!renderWorkbench._pinnedTop){
|
||||||
|
renderWorkbench._pinnedTop = true;
|
||||||
|
setTimeout(()=>{ const b = $('#originalFull') && $('#originalFull').closest('.pane-body'); if(b) b.scrollTop = 0; }, 150);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function submitConstraint(){
|
async function submitConstraint(){
|
||||||
const input = $('#constraintInput');
|
const input = $('#constraintInput');
|
||||||
@@ -1122,6 +1524,9 @@ function setOriginalView(mode){
|
|||||||
function onRewriteInput(e){
|
function onRewriteInput(e){
|
||||||
rewrites[current] = e.target.value;
|
rewrites[current] = e.target.value;
|
||||||
$('#charCount').textContent = `${e.target.value.length} characters`;
|
$('#charCount').textContent = `${e.target.value.length} characters`;
|
||||||
|
renderWordMeta();
|
||||||
|
renderTranslateBtn();
|
||||||
|
renderTranslateBox(); // 改写稿变化 → 旧译文过期自动收起(缓存按文本比对)
|
||||||
if(!e.target.value.trim()){
|
if(!e.target.value.trim()){
|
||||||
rewriteStatus[current] = 'UNTOUCHED';
|
rewriteStatus[current] = 'UNTOUCHED';
|
||||||
}else{
|
}else{
|
||||||
@@ -1132,7 +1537,7 @@ function onRewriteInput(e){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
scheduleSave(current);
|
scheduleSave(current);
|
||||||
renderParagraphSwitcher();
|
updateParagraphSwitcherState();
|
||||||
updateProgress();
|
updateProgress();
|
||||||
}
|
}
|
||||||
function onRewriteBlur(){
|
function onRewriteBlur(){
|
||||||
@@ -1141,12 +1546,14 @@ function onRewriteBlur(){
|
|||||||
if(hasRewrite(current)) rewriteStatus[current] = 'REWRITTEN';
|
if(hasRewrite(current)) rewriteStatus[current] = 'REWRITTEN';
|
||||||
else rewriteStatus[current] = 'UNTOUCHED';
|
else rewriteStatus[current] = 'UNTOUCHED';
|
||||||
flushSave(current);
|
flushSave(current);
|
||||||
renderParagraphSwitcher();
|
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)){
|
||||||
@@ -1259,6 +1666,7 @@ async function runRecheck(){
|
|||||||
if(!resp.checked_rewrite_version) resp.checked_rewrite_version = version;
|
if(!resp.checked_rewrite_version) resp.checked_rewrite_version = version;
|
||||||
removeEl(loading);
|
removeEl(loading);
|
||||||
lastRecheckResult = resp;
|
lastRecheckResult = resp;
|
||||||
|
pushRecheckHistory(resp);
|
||||||
agentStage='recheck';
|
agentStage='recheck';
|
||||||
persistNow();
|
persistNow();
|
||||||
renderRecheck(resp);
|
renderRecheck(resp);
|
||||||
@@ -1268,6 +1676,72 @@ async function runRecheck(){
|
|||||||
renderError(e.message, ()=>{ show('agentView'); addUser('重新提交全文复检。'); runRecheck(); });
|
renderError(e.message, ()=>{ show('agentView'); addUser('重新提交全文复检。'); runRecheck(); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 体验优化:复检汇总建议历史 —— 此前复检结果看一次就只剩最新一份(lastRecheckResult),
|
||||||
|
// 无法回看历次建议。这里每次复检入历史(状态/检查项/返工目标/绑定版本),
|
||||||
|
// 历史条目可直接「按此建议再次改写」回到对应段、带着当时的建议修改。
|
||||||
|
function pushRecheckHistory(r){
|
||||||
|
const rec = {
|
||||||
|
at: Date.now(),
|
||||||
|
status: r.status==='pass' ? 'pass' : 'revision_required',
|
||||||
|
version: r.checked_rewrite_version || '',
|
||||||
|
checks: r.global_checks || {},
|
||||||
|
target: r.status==='pass' ? null : ((r.revision_targets||[])[0] || null),
|
||||||
|
pass_version: null
|
||||||
|
};
|
||||||
|
recheckHistory.unshift(rec);
|
||||||
|
if(recheckHistory.length>10) recheckHistory.length = 10;
|
||||||
|
persistNow();
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
function failedCheckLabels(checks){
|
||||||
|
return Object.keys(CHECK_LABELS).filter(k=>k!=='word_limit'||wordLimit).filter(k=>{
|
||||||
|
const v = (checks||{})[k];
|
||||||
|
return !(v==='pass'||v===undefined||v===null);
|
||||||
|
}).map(k=>CHECK_LABELS[k]);
|
||||||
|
}
|
||||||
|
function renderRecheckHistory(){
|
||||||
|
const btn = $('#recheckHistoryBtn');
|
||||||
|
if(!btn) return;
|
||||||
|
btn.classList.toggle('hidden', recheckHistory.length===0);
|
||||||
|
btn.textContent = `复检历史 (${recheckHistory.length})`;
|
||||||
|
const panel = $('#recheckHistoryPanel');
|
||||||
|
if(!panel) return;
|
||||||
|
if(!recheckHistory.length){ panel.classList.remove('open'); panel.innerHTML=''; return; }
|
||||||
|
panel.innerHTML = `<div class="rhi-title">复检历史(最新在前,点建议可再次进入改写)</div>` + recheckHistory.map((rec,idx)=>{
|
||||||
|
const ok = rec.status==='pass';
|
||||||
|
const d = new Date(rec.at);
|
||||||
|
const pad = n=>String(n).padStart(2,'0');
|
||||||
|
const time = `${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
const tIdx = rec.target ? Math.max(0, parseInt(String(rec.target.paragraph_id||'p1').replace(/\D/g,''),10)-1) : -1;
|
||||||
|
const badge = ok ? '<span class="rhi-badge ok">✓ 通过</span>' : `<span class="rhi-badge warn">需返工 ${tIdx>=0?paragraphLabel(tIdx):''}</span>`;
|
||||||
|
const fails = failedCheckLabels(rec.checks);
|
||||||
|
const meta = [rec.version?`Rewrite Version ${escapeHtml(rec.version)}`:'', rec.pass_version?`生成版本 ${rec.pass_version}`:'', fails.length?`未通过检查:${fails.join('、')}`:''].filter(Boolean).join(' · ');
|
||||||
|
return `<div class="recheck-history-item">
|
||||||
|
<div class="rhi-head"><strong>${time}</strong>${badge}${idx===0?'<span class="rhi-latest">最新</span>':''}</div>
|
||||||
|
${meta?`<div class="rhi-meta">${meta}</div>`:''}
|
||||||
|
${rec.target&&rec.target.single_revision_goal?`<div class="rhi-goal"><strong>建议:</strong>${escapeHtml(rec.target.single_revision_goal)}</div>`:''}
|
||||||
|
${rec.target?`<button class="btn small rhi-revision" data-hidx="${idx}">按此建议再次改写 →</button>`:''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
panel.querySelectorAll('.rhi-revision').forEach(b=>{ b.onclick=()=>enterRevisionFromHistory(Number(b.dataset.hidx)); });
|
||||||
|
}
|
||||||
|
function toggleRecheckHistory(force){
|
||||||
|
const panel = $('#recheckHistoryPanel');
|
||||||
|
if(!panel || !recheckHistory.length) return;
|
||||||
|
const open = typeof force==='boolean' ? force : !panel.classList.contains('open');
|
||||||
|
panel.classList.toggle('open', open);
|
||||||
|
if(open){ renderRecheckHistory(); track('recheck_history_opened', {count: recheckHistory.length}); }
|
||||||
|
}
|
||||||
|
function enterRevisionFromHistory(hidx){
|
||||||
|
const rec = recheckHistory[hidx];
|
||||||
|
if(!rec || !rec.target) return;
|
||||||
|
// 选中历史建议 = 当前返工目标:左栏返工 banner / 对话页 return 按钮都按它工作
|
||||||
|
revisionTarget = rec.target;
|
||||||
|
revisionMode = true;
|
||||||
|
toggleRecheckHistory(false);
|
||||||
|
track('recheck_history_revision_entered', {paragraph_id: rec.target.paragraph_id});
|
||||||
|
enterRevision(rec.target.paragraph_id);
|
||||||
|
}
|
||||||
function renderRecheck(r){
|
function renderRecheck(r){
|
||||||
const checks=r.global_checks||{};
|
const checks=r.global_checks||{};
|
||||||
const keys = Object.keys(CHECK_LABELS).filter(k=> k!=='word_limit' || wordLimit);
|
const keys = Object.keys(CHECK_LABELS).filter(k=> k!=='word_limit' || wordLimit);
|
||||||
@@ -1292,8 +1766,9 @@ function renderRecheck(r){
|
|||||||
const nextVersion = essayVersions.length + 2;
|
const nextVersion = essayVersions.length + 2;
|
||||||
essayVersions.push({version: nextVersion, text: fullText});
|
essayVersions.push({version: nextVersion, text: fullText});
|
||||||
viewedVersion = nextVersion;
|
viewedVersion = nextVersion;
|
||||||
|
if(recheckHistory[0]) recheckHistory[0].pass_version = nextVersion; // 历史条目关联成品版本
|
||||||
persistNow();
|
persistNow();
|
||||||
addAgent(`<div class="eyebrow">Human Voice Recheck</div><h1>本轮 Human Voice Rewrite 已完成。</h1>${ver}<p>我把 ${paragraphs.length} 段放回完整文章重新读了一遍。本次只做最终验收,没有重新开启一轮问题盘点。</p><table class="agent-table"><thead><tr><th>检查项</th><th style="width:190px">状态</th></tr></thead><tbody>${rows}</tbody></table><div class="inline-note"><strong>可以结束这一轮 Human Voice Rewrite。</strong>剩余问题主要属于个人风格选择,不建议继续为了“更不像 AI”大面积修改。</div><p class="muted">本次改写后的全文已生成 <strong>版本 ${nextVersion}</strong>。点击下方按钮回文书页,可在文书页顶部切换「原版 / 版本 ${nextVersion}」对比。</p><div class="quick-actions"><button class="quick primary" data-action="view-version2">查看版本 ${nextVersion}(修改后的全文)→</button></div>`);
|
addAgent(`<div class="eyebrow">Human Voice Recheck</div><h1>本轮 Human Voice Rewrite 已完成。</h1>${ver}<p>我把 ${paragraphs.length} 段放回完整文章重新读了一遍。本次只做最终验收,没有重新开启一轮问题盘点。</p><table class="agent-table"><thead><tr><th>检查项</th><th style="width:190px">状态</th></tr></thead><tbody>${rows}</tbody></table><div class="inline-note"><strong>可以结束这一轮 Human Voice Rewrite。</strong>剩余问题主要属于个人风格选择,不建议继续为了“更不像 AI”大面积修改。</div><p class="muted">本次改写后的全文已生成 <strong>版本 ${nextVersion}</strong>。点击下方按钮回文书页,可在文书页顶部切换「原版 / 版本 ${nextVersion}」对比。</p><div class="quick-actions"><button class="quick primary" data-action="view-version2">查看版本 ${nextVersion}(修改后的全文)→</button><button class="quick ghost" data-action="view-recheck-history">查看复检历史</button></div>`);
|
||||||
bindQuickActions();
|
bindQuickActions();
|
||||||
disableCompletedActions(); // 通过后收紧:历史返工卡按钮按 revisionTarget=null 置灰(用户实测 2)
|
disableCompletedActions(); // 通过后收紧:历史返工卡按钮按 revisionTarget=null 置灰(用户实测 2)
|
||||||
}else{
|
}else{
|
||||||
@@ -1303,10 +1778,11 @@ function renderRecheck(r){
|
|||||||
const idx=Math.max(0, parseInt(String(t.paragraph_id||'p1').replace(/\D/g,''),10)-1);
|
const idx=Math.max(0, parseInt(String(t.paragraph_id||'p1').replace(/\D/g,''),10)-1);
|
||||||
const ev=t.evidence&&t.evidence.length?`<div class="constraint-box"><strong>Evidence:</strong><br>${t.evidence.map(x=>'• '+escapeHtml(x)).join('<br>')}</div>`:'';
|
const ev=t.evidence&&t.evidence.length?`<div class="constraint-box"><strong>Evidence:</strong><br>${t.evidence.map(x=>'• '+escapeHtml(x)).join('<br>')}</div>`:'';
|
||||||
track('recheck_revision_required', {paragraph_id: t.paragraph_id});
|
track('recheck_revision_required', {paragraph_id: t.paragraph_id});
|
||||||
addAgent(`<div class="eyebrow">Human Voice Recheck</div><h1>全文复检:${escapeHtml(paragraphLabel(idx))} 还需要返工一次</h1>${ver}<p>这次不重复首轮的 Pattern 教学,只看本轮重写有没有真正生效、上下文是否统一。</p><table class="agent-table"><thead><tr><th>检查项</th><th style="width:190px">状态</th></tr></thead><tbody>${rows}</tbody></table><h2>这次只返工 ${escapeHtml(paragraphLabel(idx))}</h2><p>${escapeHtml(t.blocking_issue)}</p>${ev}<div class="summary"><strong>本次只返工一件事:</strong>${escapeHtml(t.single_revision_goal)}</div><div class="quick-actions"><button class="quick primary" data-action="return-${escapeHtml(t.paragraph_id)}">返回 ${escapeHtml(paragraphLabel(idx))} 重写 →</button></div>`);
|
addAgent(`<div class="eyebrow">Human Voice Recheck</div><h1>全文复检:${escapeHtml(paragraphLabel(idx))} 还需要返工一次</h1>${ver}<p>这次不重复首轮的 Pattern 教学,只看本轮重写有没有真正生效、上下文是否统一。</p><table class="agent-table"><thead><tr><th>检查项</th><th style="width:190px">状态</th></tr></thead><tbody>${rows}</tbody></table><h2>这次只返工 ${escapeHtml(paragraphLabel(idx))}</h2><p>${escapeHtml(t.blocking_issue)}</p>${ev}<div class="summary"><strong>本次只返工一件事:</strong>${escapeHtml(t.single_revision_goal)}</div><div class="quick-actions"><button class="quick primary" data-action="return-${escapeHtml(t.paragraph_id)}">返回 ${escapeHtml(paragraphLabel(idx))} 重写 →</button><button class="quick ghost" data-action="view-recheck-history">查看复检历史</button></div>`);
|
||||||
bindQuickActions();
|
bindQuickActions();
|
||||||
disableCompletedActions();
|
disableCompletedActions();
|
||||||
}
|
}
|
||||||
|
renderRecheckHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCKS=[
|
const MOCKS=[
|
||||||
@@ -1345,7 +1821,9 @@ $('#backToChat').onclick=()=>{
|
|||||||
disableCompletedActions();
|
disableCompletedActions();
|
||||||
};
|
};
|
||||||
$('#sendChat').onclick=()=>processChat($('#chatInput').value);
|
$('#sendChat').onclick=()=>processChat($('#chatInput').value);
|
||||||
$('#chatInput').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();processChat(e.target.value);}});
|
// 中文输入法组合期(拼音还没上屏)回车是在选候选词,不是发送 —— 此前无守卫导致
|
||||||
|
// 打字打到一半就被发出去(用户实测)。keyCode 229 兼容部分输入法组合结束那一下。
|
||||||
|
$('#chatInput').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey&&!e.isComposing&&e.keyCode!==229){e.preventDefault();processChat(e.target.value);}});
|
||||||
$('#chatInput').addEventListener('input',()=>{const h=$('#inputHint');if(h)h.classList.add('hidden');});
|
$('#chatInput').addEventListener('input',()=>{const h=$('#inputHint');if(h)h.classList.add('hidden');});
|
||||||
$('#focusOriginal').onclick=()=>setOriginalView('focus');
|
$('#focusOriginal').onclick=()=>setOriginalView('focus');
|
||||||
$('#fullOriginal').onclick=()=>setOriginalView('full');
|
$('#fullOriginal').onclick=()=>setOriginalView('full');
|
||||||
@@ -1353,6 +1831,17 @@ $('#rewriteArea').addEventListener('input', onRewriteInput);
|
|||||||
$('#rewriteArea').addEventListener('blur', onRewriteBlur);
|
$('#rewriteArea').addEventListener('blur', onRewriteBlur);
|
||||||
$('#showScaffold').onclick=toggleScaffold;
|
$('#showScaffold').onclick=toggleScaffold;
|
||||||
$('#showReference').onclick=toggleReference;
|
$('#showReference').onclick=toggleReference;
|
||||||
|
$('#copyOriginalBtn').onclick=copyOriginalToRewrite;
|
||||||
|
$('#translateBtn').onclick=toggleTranslate;
|
||||||
|
$('#recheckHistoryBtn').onclick=()=>toggleRecheckHistory();
|
||||||
|
// 点击复检历史面板与开关按钮以外的区域自动收起
|
||||||
|
document.addEventListener('click', e=>{
|
||||||
|
const panel = $('#recheckHistoryPanel');
|
||||||
|
if(!panel || !panel.classList.contains('open')) return;
|
||||||
|
if(panel.contains(e.target)) return;
|
||||||
|
if(e.target.closest && e.target.closest('#recheckHistoryBtn, [data-action="view-recheck-history"]')) return;
|
||||||
|
toggleRecheckHistory(false);
|
||||||
|
});
|
||||||
$('#prevPara').onclick=()=>{ if(current>0) switchParagraph(current-1); };
|
$('#prevPara').onclick=()=>{ if(current>0) switchParagraph(current-1); };
|
||||||
$('#nextPara').onclick=()=>{ if(current<paragraphs.length-1) switchParagraph(current+1); };
|
$('#nextPara').onclick=()=>{ if(current<paragraphs.length-1) switchParagraph(current+1); };
|
||||||
$('#addConstraintBtn').onclick=toggleConstraintEntry;
|
$('#addConstraintBtn').onclick=toggleConstraintEntry;
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -20,6 +23,8 @@ from prompts import (
|
|||||||
build_recheck_prompt,
|
build_recheck_prompt,
|
||||||
build_reference_prompt,
|
build_reference_prompt,
|
||||||
build_scaffold_prompt,
|
build_scaffold_prompt,
|
||||||
|
build_translate_prompt,
|
||||||
|
build_translate_sentences_prompt,
|
||||||
normalize_diagnose_payload,
|
normalize_diagnose_payload,
|
||||||
)
|
)
|
||||||
from schemas import (
|
from schemas import (
|
||||||
@@ -35,6 +40,9 @@ from schemas import (
|
|||||||
RevisionTarget,
|
RevisionTarget,
|
||||||
ScaffoldRequest,
|
ScaffoldRequest,
|
||||||
ScaffoldResponse,
|
ScaffoldResponse,
|
||||||
|
SentencePair,
|
||||||
|
TranslateRequest,
|
||||||
|
TranslateResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = FastAPI(title="Human Voice Rewrite Demo", version="1.1.0")
|
app = FastAPI(title="Human Voice Rewrite Demo", version="1.1.0")
|
||||||
@@ -198,6 +206,100 @@ def reference(req: ReferenceRequest) -> ReferenceResponse:
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/translate", response_model=TranslateResponse)
|
||||||
|
def translate(req: TranslateRequest) -> TranslateResponse:
|
||||||
|
"""体验优化:中文对照翻译。
|
||||||
|
- text 整段模式:改写区「译成中文对照」(自查改写后原意/细节是否保留)
|
||||||
|
- sentences 逐句模式:原文区「原文中文对照」(前端拆分句子,逐句对位,
|
||||||
|
让「需要改写的高亮句」在中文对照里能同色找到)。"""
|
||||||
|
if req.sentences:
|
||||||
|
return _translate_sentences(req)
|
||||||
|
if not req.text.strip():
|
||||||
|
raise HTTPException(status_code=422, detail="没有可翻译的文本")
|
||||||
|
system, user = build_translate_prompt(req.text)
|
||||||
|
data = run_json("翻译", system, user)
|
||||||
|
translation = data.get("translation")
|
||||||
|
if isinstance(translation, list): # 模型偶发把字符串字段返回成数组
|
||||||
|
translation = "".join(str(x) for x in translation)
|
||||||
|
resp = TranslateResponse(paragraph_id=req.paragraph_id, translation=str(translation or "").strip())
|
||||||
|
if not resp.translation:
|
||||||
|
raise HTTPException(status_code=502, detail="翻译结果为空,请重试")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _translate_sentences(req: TranslateRequest) -> TranslateResponse:
|
||||||
|
clean = [s for s in (x.strip() for x in req.sentences) if s]
|
||||||
|
if not clean:
|
||||||
|
raise HTTPException(status_code=422, detail="没有可翻译的句子")
|
||||||
|
system, user = build_translate_sentences_prompt(clean)
|
||||||
|
# 句子数量校验复用 _count_check:不一致自动重试一次(对位可靠性依赖等长同序)
|
||||||
|
data = run_json("翻译", system, user, check=_count_check(len(clean), "sentences"))
|
||||||
|
items = data.get("sentences") or []
|
||||||
|
pairs: list[SentencePair] = []
|
||||||
|
for i, en in enumerate(clean):
|
||||||
|
zh = ""
|
||||||
|
if i < len(items) and isinstance(items[i], dict):
|
||||||
|
value = items[i].get("zh")
|
||||||
|
if isinstance(value, list): # 模型偶发把字符串字段返回成数组
|
||||||
|
value = "".join(str(x) for x in value)
|
||||||
|
zh = str(value or "").strip()
|
||||||
|
pairs.append(SentencePair(en=en, zh=zh))
|
||||||
|
if not any(p.zh for p in pairs):
|
||||||
|
raise HTTPException(status_code=502, detail="翻译结果为空,请重试")
|
||||||
|
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")
|
||||||
|
|||||||
+32
-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)。
|
||||||
@@ -307,6 +307,37 @@ def build_reference_prompt(
|
|||||||
return system, user
|
return system, user
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 体验优化:中文对照翻译(顾问在改写区自查「改写后原意有没有跑偏」)
|
||||||
|
def build_translate_prompt(text: str) -> tuple[str, str]:
|
||||||
|
system = """你是 Human Voice Rewrite 工作流的中文对照翻译器。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 把用户给的英文文本逐句翻译成自然中文,供顾问核对「改写后是否守住了原意」。
|
||||||
|
- 忠实优先:保留细节、语气与情绪强度;不润色、不省略、不补写原文没有的信息。
|
||||||
|
- 不做评价、不建议、不加注释;只输出译文本身。
|
||||||
|
- 只输出 JSON:{"translation": ""}"""
|
||||||
|
|
||||||
|
user = f"英文文本:\n{text}\n\n请输出 JSON。"
|
||||||
|
return system, user
|
||||||
|
|
||||||
|
|
||||||
|
def build_translate_sentences_prompt(sentences: list[str]) -> tuple[str, str]:
|
||||||
|
"""逐句中文对照(原文区):句子由前端拆分,模型只负责逐句翻译,
|
||||||
|
数量与顺序必须一致 —— 前端据此把「需要改写的高亮句」在中文对照里同色对位。"""
|
||||||
|
numbered = "\n".join(f"{i + 1}. {s}" for i, s in enumerate(sentences))
|
||||||
|
system = """你是 Human Voice Rewrite 工作流的中文对照翻译器。
|
||||||
|
|
||||||
|
用户给出的是一段英文文书的逐句拆分(编号)。要求:
|
||||||
|
- 逐句翻译成自然中文,与输入一一对应:数量一致、顺序不变;每句只翻这一句,不合并、不遗漏。
|
||||||
|
- 忠实优先:保留细节、语气与情绪强度;不润色、不省略、不补写原文没有的信息。
|
||||||
|
- 不做评价、不建议、不加注释。
|
||||||
|
- 只输出 JSON:{"sentences": [{"zh": ""}]}(按输入顺序,数量与输入完全一致)"""
|
||||||
|
|
||||||
|
user = f"英文句子:\n{numbered}\n\n请输出 JSON。"
|
||||||
|
return system, user
|
||||||
|
|
||||||
|
|
||||||
def _as_text(value, sep=";") -> str:
|
def _as_text(value, sep=";") -> str:
|
||||||
"""模型偶发把字符串字段返回成数组(如 ai_focus 给了 1–3 类问题的列表,
|
"""模型偶发把字符串字段返回成数组(如 ai_focus 给了 1–3 类问题的列表,
|
||||||
2026-08-21 用户实测 4 段全中)——统一规整为字符串,避免 pydantic
|
2026-08-21 用户实测 4 段全中)——统一规整为字符串,避免 pydantic
|
||||||
|
|||||||
+21
@@ -167,3 +167,24 @@ class ReferenceResponse(BaseModel):
|
|||||||
paragraph_id: str = ""
|
paragraph_id: str = ""
|
||||||
starter: str = "" # 1-2 句英文局部参考,非整段
|
starter: str = "" # 1-2 句英文局部参考,非整段
|
||||||
reference_snippet: str = ""
|
reference_snippet: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- 体验优化:中文对照翻译
|
||||||
|
class SentencePair(BaseModel):
|
||||||
|
en: str = "" # 原文句子(前端拆分,服务端原样回填,保证高亮对位可靠)
|
||||||
|
zh: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class TranslateRequest(BaseModel):
|
||||||
|
# 整段模式(改写稿翻译):text 必填;
|
||||||
|
# 逐句模式(原文中文对照):sentences 必填。二者取其一。
|
||||||
|
text: str = ""
|
||||||
|
sentences: list[str] = []
|
||||||
|
# 段落上下文(可选):帮助模型稳定语气,不参与翻译输出
|
||||||
|
paragraph_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class TranslateResponse(BaseModel):
|
||||||
|
paragraph_id: str = ""
|
||||||
|
translation: str = "" # 整段模式
|
||||||
|
sentences: list[SentencePair] = [] # 逐句模式(与请求句子等长、同序)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -510,6 +510,51 @@ def test_api_scaffold_shape():
|
|||||||
assert "liked" in r.json()["scaffold"]
|
assert "liked" in r.json()["scaffold"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_translate_shape():
|
||||||
|
payload = {"translation": "我不断回到这个问题上。"}
|
||||||
|
r = api_client(payload).post(
|
||||||
|
"/api/translate",
|
||||||
|
json={"text": "I kept coming back to the problem.", "paragraph_id": "p3"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["translation"].startswith("我不断回到")
|
||||||
|
assert r.json()["paragraph_id"] == "p3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_translate_coerces_list_translation():
|
||||||
|
"""模型偶发把 translation 返回成数组 —— 归并为一个字符串,不 502。"""
|
||||||
|
r = api_client({"translation": ["第一句。", "第二句。"]}).post(
|
||||||
|
"/api/translate", json={"text": "One. Two."}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["translation"] == "第一句。第二句。"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_translate_sentences_shape():
|
||||||
|
"""原文中文对照(逐句):en 原样回填、zh 按序对位 —— 前端据此做高亮对位。"""
|
||||||
|
payload = {"sentences": [{"zh": "第一句译文。"}, {"zh": "第二句译文。"}]}
|
||||||
|
r = api_client(payload).post(
|
||||||
|
"/api/translate", json={"sentences": ["One.", "Two."], "paragraph_id": "p1"}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert [s["en"] for s in data["sentences"]] == ["One.", "Two."]
|
||||||
|
assert data["sentences"][1]["zh"] == "第二句译文。"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_translate_sentences_blank_422():
|
||||||
|
r = api_client({"sentences": [{"zh": "x"}]}).post(
|
||||||
|
"/api/translate", json={"sentences": [" ", ""]}
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_translate_empty_translation_502():
|
||||||
|
r = api_client({"translation": " "}).post("/api/translate", json={"text": "Hello."})
|
||||||
|
assert r.status_code == 502
|
||||||
|
assert "翻译结果为空" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
def test_complete_json_paragraph_count_mismatch_retries_once():
|
def test_complete_json_paragraph_count_mismatch_retries_once():
|
||||||
"""语义校验:段落数不匹配视为一次失败并自动重试,重试成功返回完整结果。
|
"""语义校验:段落数不匹配视为一次失败并自动重试,重试成功返回完整结果。
|
||||||
真实 LLM 偶发返回合法 JSON 但段落条目不全(AI 初审"4 段只显示 1 段"),
|
真实 LLM 偶发返回合法 JSON 但段落条目不全(AI 初审"4 段只显示 1 段"),
|
||||||
|
|||||||
@@ -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