mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:51:57 +00:00
cc608f56ec
「跑完了」和「答得对」之间一直没有任何一层。库里 264 条 succeeded 里, 14 条带着 warn、4 条 agent 步数用满,而交到用户手上的答案对此只字不提。 最糟的一次(run dd9927e6)交出去的干脆是「本轮只执行了第一个工具」—— 一句内部管道文案冒充结论。 出具体系(issuance.py)本来就是干这个的,但它挂在输出节点的可选契约上, 还要配口径卡提供受控指标集;问数据页生成的图基本都是 input → agent → output, 走不到那条路。所以补一层轻的。 **干净的运行一次模型都不调。** 先用规则扫一遍事件流(engine/review.py:scan), 没扫出东西就到此为止。实测 15ms 返回。这是整层设计的前提,测试里用 「monkeypatch 一个会抛异常的模型,断言它没被碰过」来守。 **分类挂在 code 上,不是中文消息串。** 12 处 warn 发射点都带上 code。 这样「流式失败,回退非流式」能归成 benign 直接跳过——它对答案内容零影响, 为它叫一次模型等于每次网络抖一下就多花一笔;而「步数用满」归 broken。 原来这两者在事件流里长得一模一样。工具报错之后同一个工具又跑成了, 算 degraded 不算 broken——否则参数自愈每修一次就报一次「答案不可信」。 **复核不许现编数字。** 它手上只有原答案和一份异常清单,没有任何原始数据, 写出来的新数字只能是凭空来的。直接复用 issuance.trace_numbers,把原答案的 数字当受控指标集,对不上就整条退回原答案、只留说明。硬拦截,不是提示。 **规则管重试资格,模型只有否决权。** 一开始写成两边取并,实测 loop_limit 时模型判 retry=true——而重跑一遍循环还是会撞上限,白烧一次建图加一次跑图。 反过来之后,「检索什么都没查到」这类也不会被模型强推重跑。 **重试带着失败的那张图,不是推倒重来。** 原来传 base_graph=null,模型只看到 「原问题 + 一句失败原因」,很可能原样再建出同一张坏图。而且它连自己上次建了 什么都看不到——会话历史按 status != running 过滤,正在重试的这一轮恰好被 排除在外。现在交回去走后端的改图分支,调大步数、改正参数就是改一个字段。 上限一次。 **答案等复核完成再显示。** 端到端抓到的:答案先摆出来,十秒后说明才追上去—— 这十秒里用户读的是一个没人核对过的结论,正是要修的那件事本身。改成落库立刻 (防关标签页丢结果)、显示等复核,并给复核加 25s 上限。说明排在成果上方, broken 用报警色、degraded 用普通边框——混成一个样子用户就学会了一概忽略。 复核结论跟着轮次落库,刷新后还在;也进下一轮的对话历史,否则模型会拿一个 已知不可信的结论当事实继续往下推。 验证:后端 242 passed(新增 test_review.py 25 条);tsc 干净; check-decode / check-stream / check-ui 全过,check-stream 新增 9 条渲染断言。 两条端到端都在真浏览器 + 真模型上跑通:检索降级那次说明正确排在答案上方; 重试那次建图请求 2 次、第二次 base_graph 带着失败的 3 个节点、第一版没漏出去。 复核结果不是事件流,decode.ts 翻译不到它,所以渲染测试放在 check-stream。
233 lines
9.5 KiB
TypeScript
233 lines
9.5 KiB
TypeScript
import { useState } from 'react'
|
||
import { createRoot } from 'react-dom/client'
|
||
import { Database, MessageSquare } from 'lucide-react'
|
||
import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantStream'
|
||
import { Markdown } from '../run/Markdown'
|
||
import { decodeRun } from '../run/decode'
|
||
import { ToastHost } from '../components/ui'
|
||
import fixtures from '../run/__tests__/fixtures.json'
|
||
import mdSamples from '../run/__tests__/markdown-samples.json'
|
||
import '../index.css'
|
||
|
||
/**
|
||
* AssistantStream 的离线预览。
|
||
*
|
||
* 只有 dev server 会加载(vite build 的 input 只有 index.html),所以它不进
|
||
* 生产包。存在的理由:要改这个组件的排版,不该每次都真跑一次图——真跑一次
|
||
* 意味着要连数据库、等模型、还得凑巧撞上人工介入分支才能看到审批卡长什么样。
|
||
* 这里用的是真实导出的事件,走的是同一个 decodeRun,只有事件来源是静态的。
|
||
*
|
||
* 打开 http://localhost:5273/preview.html
|
||
*/
|
||
|
||
type Bucket = keyof typeof fixtures
|
||
|
||
const CASES: { key: Bucket; question: string; phase: StreamTurn['phase']; status: string }[] = [
|
||
{ key: 'db', question: '这个库里有多少数据?挑最大的表说说', phase: 'done', status: '完成' },
|
||
{ key: 'think', question: '分析上季度华东区销量下滑的原因', phase: 'running', status: '正在执行…' },
|
||
{ key: 'human', question: '把这批客户的信用额度调高 10%', phase: 'waiting', status: '等待你的确认' },
|
||
{ key: 'issue', question: '出一份本月经营分析', phase: 'done', status: '完成' },
|
||
{ key: 'failed', question: '查一下不存在的那张表', phase: 'error', status: '' },
|
||
{ key: 'loop_approve', question: '写条上线公告,我过一眼再发', phase: 'done', status: '完成' },
|
||
{ key: 'supervisor', question: '冬虫夏草是什么', phase: 'done', status: '完成' },
|
||
]
|
||
|
||
function buildTurns(): StreamTurn[] {
|
||
return CASES.map((c) => {
|
||
const events = (fixtures as any)[c.key] as any[]
|
||
const steps = decodeRun(events)
|
||
const finished = events.find((e) => e.type === 'run.finished')
|
||
const failed = events.find((e) => e.type === 'run.failed')
|
||
return {
|
||
id: c.key,
|
||
question: c.question,
|
||
phase: c.phase,
|
||
status: c.status,
|
||
steps,
|
||
thinking: c.phase === 'running'
|
||
? '先看一下 ANALYTICS 里有哪些和销量相关的视图,再按区域和月份聚合……'
|
||
: undefined,
|
||
output: finished?.data?.output ?? null,
|
||
error: failed ? String(failed.data?.error ?? '运行失败') : undefined,
|
||
runId: events[0]?.run_id,
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 一段恶意输入。这类测例必须是构造的——真实输出不会自带攻击,但模型输出
|
||
* 是不可信内容,渲染层要么天生免疫,要么就是个 XSS 口子。
|
||
*/
|
||
const HOSTILE = [
|
||
'正常文本 **加粗**。',
|
||
'',
|
||
'<script>window.__pwned = 1</script>',
|
||
'<img src=x onerror="window.__pwned=1">',
|
||
'[点我](javascript:window.__pwned=1)',
|
||
'[正常链接](https://example.com)',
|
||
'<iframe src="https://evil.example"></iframe>',
|
||
'`<script>alert(1)</script>`',
|
||
].join('\n')
|
||
|
||
/**
|
||
* 一份会超出折叠上限的报告,表格正好横跨切点。
|
||
*
|
||
* 之前折叠是按字符硬切的,切点落进表格中间——前面几行渲染成表格、最后半行
|
||
* 留成原始的 `| 1 | ThearchyHelios | …`。用户看到的是一份被咬掉一口的报告,
|
||
* 而它其实是完整的,只是被折叠了。
|
||
*/
|
||
function longReport(): string {
|
||
const head = [
|
||
"## 总结", "",
|
||
"- **管理员(平台级/区域级角色)**:共 9 人", "- **商家账户**:共 5 个", "",
|
||
"口径说明:".padEnd(400, "口"), "",
|
||
"## 一、管理员名单", "",
|
||
].join("\n")
|
||
const rows = Array.from({ length: 40 }, (_, i) =>
|
||
`| ${i + 1} | user_${i} | user${i}@example.com | 昵称${i} | ` +
|
||
`85e11976-6a7b-4167-b561-deec4fd${String(i).padStart(4, "0")} | 商家 ${i} | c${i}@example.com |`)
|
||
return [
|
||
head,
|
||
"| userID | 用户名 | 邮箱 | 昵称 | providerID | 商家名称 | 联系人邮箱 |",
|
||
"| --- | --- | --- | --- | --- | --- | --- |",
|
||
...rows,
|
||
"", "> 说明:部分账户身兼管理员与商家双重身份。",
|
||
].join("\n")
|
||
}
|
||
|
||
/** 直接回答的那一轮:没查库,界面上必须说破 */
|
||
const NO_QUERY_TURN: StreamTurn = {
|
||
id: "noquery", question: "刚才那 6 个管理员是怎么算的?", phase: "done", status: "完成",
|
||
steps: [], noQuery: true,
|
||
output: { answer: "第 1 轮走的是 `role.level IN (platform, regional)` 这个口径。要换口径我重新查一遍。" },
|
||
}
|
||
|
||
/** 复核判了「不可信」:说明排在成果上方,且用报警色 */
|
||
const REVIEW_BROKEN_TURN: StreamTurn = {
|
||
id: "reviewbroken", question: "商家账户有多少个?", phase: "done", status: "完成",
|
||
steps: [],
|
||
review: {
|
||
verdict: "annotated", severity: "broken", retry: true,
|
||
note: "这次取数没跑通(表名对不上),下面这个数是模型根据上下文推断的,不能当结论用。",
|
||
answer: null,
|
||
signals: [
|
||
{ kind: "tool_error", severity: "broken",
|
||
detail: "工具 db_query__shop 调用失败:no such table: merchant_account" },
|
||
{ kind: "step_limit", severity: "broken", detail: "agent 用满了 8 步还没给出结论" },
|
||
],
|
||
},
|
||
output: { answer: "大约 5 个左右。" },
|
||
}
|
||
|
||
/** 复核只是补了一句说明,答案照常给;外加改写前的原文可展开 */
|
||
const REVIEW_DEGRADED_TURN: StreamTurn = {
|
||
id: "reviewdegraded", question: "谁能改别人的权限?", phase: "done", status: "完成",
|
||
steps: [],
|
||
review: {
|
||
verdict: "rewritten", severity: "degraded", retry: false,
|
||
note: "这次知识库检索退回了纯关键词匹配,换了说法的内容可能没被搜到。",
|
||
answer: null,
|
||
signals: [
|
||
{ kind: "retrieve_degraded", severity: "degraded",
|
||
detail: "重排结果解析不出来,按初筛顺序返回" },
|
||
],
|
||
},
|
||
output: { answer: "只有平台管理员可以修改他人权限,且需要走角色变更流程。" },
|
||
rawOutput: { answer: "平台管理员可以修改他人权限。" },
|
||
}
|
||
|
||
const LONG_TURN: StreamTurn = {
|
||
id: "long", question: "需要能够显示总结内容", phase: "done", status: "完成",
|
||
steps: [], output: { result: longReport() },
|
||
}
|
||
|
||
/** Markdown 渲染用真实输出验,不用编的样本 */
|
||
function MarkdownCases() {
|
||
return (
|
||
<div className="mx-auto max-w-3xl space-y-4 p-4">
|
||
{[...(mdSamples as string[]), HOSTILE].map((t, i) => (
|
||
<div key={i} className="rounded-lg border bg-panel p-3">
|
||
<div className="mb-2 text-[10px] text-faint">真实输出 #{i + 1}</div>
|
||
<Markdown text={t} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Preview() {
|
||
// ?case=db 只看一个用例,?theme=light 直接出浅色——都是为了截图可复现,
|
||
// 不用靠点按钮
|
||
const params = new URLSearchParams(location.search)
|
||
const only = params.get('case')
|
||
const md = params.get('md') === '1'
|
||
if (params.get('theme') === 'light') document.documentElement.dataset.theme = 'light'
|
||
const [all] = useState(buildTurns)
|
||
const turns = only ? all.filter((t) => t.id === only) : all
|
||
const [dense, setDense] = useState(false)
|
||
const [emptyState, setEmptyState] = useState(false)
|
||
|
||
const empty = (
|
||
<StreamEmpty
|
||
icon={<MessageSquare size={22} />}
|
||
title="问点什么"
|
||
hint="它会自己接数据源、写查询、跑完给结论——你不用碰画布"
|
||
/>
|
||
)
|
||
|
||
if (md) return <div className="h-full overflow-y-auto"><MarkdownCases /></div>
|
||
if (params.get('long') === '1') {
|
||
return <AssistantStream turns={[LONG_TURN]} />
|
||
}
|
||
if (params.get('noquery') === '1') {
|
||
return <AssistantStream turns={[NO_QUERY_TURN]} />
|
||
}
|
||
if (params.get('review') === '1') {
|
||
return <AssistantStream turns={[REVIEW_BROKEN_TURN, REVIEW_DEGRADED_TURN]} />
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full flex-col">
|
||
<header className="flex items-center gap-3 border-b px-4 py-2 text-[12px]">
|
||
<Database size={14} style={{ color: 'var(--accent)' }} />
|
||
<span className="font-semibold">AssistantStream 预览</span>
|
||
<span className="text-faint">真实事件 · 离线渲染</span>
|
||
<span className="flex-1" />
|
||
<label className="flex items-center gap-1.5 text-[11.5px] text-dim">
|
||
<input type="checkbox" checked={dense} onChange={(e) => setDense(e.target.checked)} />
|
||
dense(360px 窄栏)
|
||
</label>
|
||
<label className="flex items-center gap-1.5 text-[11.5px] text-dim">
|
||
<input type="checkbox" checked={emptyState}
|
||
onChange={(e) => setEmptyState(e.target.checked)} />
|
||
空态
|
||
</label>
|
||
<button className="btn btn-sm btn-ghost"
|
||
onClick={() => {
|
||
const el = document.documentElement
|
||
el.dataset.theme = el.dataset.theme === 'light' ? '' : 'light'
|
||
}}>
|
||
切主题
|
||
</button>
|
||
</header>
|
||
|
||
<div className="flex min-h-0 flex-1">
|
||
{/* 宽屏:问数据页的形态 */}
|
||
<div className="min-w-0 flex-1 border-r">
|
||
<AssistantStream turns={emptyState ? [] : turns} empty={empty} />
|
||
</div>
|
||
{/* 窄栏:画布右侧助手栏的形态。固定 360px,和真实右栏一致 */}
|
||
<div className="w-[360px] shrink-0 bg-panel">
|
||
<AssistantStream turns={emptyState ? [] : turns} dense empty={empty} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
createRoot(document.getElementById('root')!).render(
|
||
<ToastHost>
|
||
<Preview />
|
||
</ToastHost>,
|
||
)
|