Skip to content

Commit 36c7246

Browse files
nieaoclaude
andcommitted
feat: 节点重叠修复 + 置信度严格 + bot inbox 监听 chatId
问题 1 — 画布节点重叠严重: - agent 横向只错 60px 但节点宽 220px, 同 stage 多 role 严重叠在一起 - ROW_H 220 对 ontologyNode (140 高 + 标签) 偏紧, task/agent 行垂直碰撞 - conclusion 用 +220 偏移在 +40*stage 之上, stageMax≥2 时贴 agent 修复: - agent 不再按 anchor task 对齐, 改全局横向均分 (AGENT_COL_W 300) - ROW_H 220→240, COL_W 260→280 (留充足 gap) - conclusion y = AGENT_Y_BASE + (stageMax-1)*100 + 240 (脱离 agent 行) - projectGroup 宽高动态扩 (totalAgentCount * AGENT_COL_W + 200, 高随 stageMax) 问题 2 — 置信度标准飘: - 旧 prompt "0-100 综合可行性 + 信息完备度 + 风险可控性" 太空, LLM 默认锚定 80-90 - 真实场景实测全部 85, hold/pivot 几乎从不出现 修复 (aiService.js DECISION_ENGINE_SYSTEM_PROMPT): - 给 5 段 score 区间明确含义 (90-100 极少给 / 75-89 推荐 / 60-74 hold / 40-59 pivot / <40 答非所问) - 强约束: hold 必须 < 75, pivot 必须 < 60 - 默认偏保守: 不到三件事 (具体数字 / 多场景对比 / 风险已识别+缓解) 都到位前不给 80+ - improvements/next_steps 加可执行 + 可量化要求 (反例直接列出) 问题 3 — bot 反馈卡只能由 feishu 消息触发: - 旧逻辑: 只有 castAletheiaPrompt (handleMessage 调用) 才 registerPendingFeedback - 测试脚本 / 第三方 cast 不通过 feishu, bot 拿不到 chatId, 反馈卡发不出来 修复 (feishu-bot.mjs): - ensureReverseChannel 加 yInbox observer, 见到含 attribution.chatId 的新 inbox item 自动 registerPendingFeedback({ chatId, prompt, sentAt }) - start() 启动时预热 ensureReverseChannel(DEFAULT_ROOM), bot 一启动就盯 inbox - 配套 server/test-full-e2e.mjs: 测试脚本只 cast 不依赖 feishu 消息, 完整闭环 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5aa6ca5 commit 36c7246

4 files changed

Lines changed: 188 additions & 32 deletions

File tree

server/feishu-bot.mjs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -548,17 +548,40 @@ function ensureReverseChannel(room) {
548548
// setLocalState(null) 让 bot 在 awareness 里不可见, 用户 cc 不会把它算作竞争者
549549
try { provider.awareness?.setLocalState(null) } catch {}
550550
const yNodes = doc.getMap('nodes')
551+
const yInbox = doc.getMap('aletheia-inbox')
551552

552553
// 等同步完成才记 baseline (否则会把已有节点都算成"新"的)
553554
const baseline = new Set()
555+
const inboxBaseline = new Set()
554556
const sentConclusions = new Set()
555557

556558
const onSync = () => {
557559
yNodes.forEach((_, k) => baseline.add(k))
558-
log(`[reverse] room=${room} synced, 基线 ${baseline.size} 节点`)
560+
yInbox.forEach((_, k) => inboxBaseline.add(k))
561+
log(`[reverse] room=${room} synced, 基线 ${baseline.size} 节点 / inbox ${inboxBaseline.size}`)
559562
}
560563
provider.once('synced', onSync)
561564

565+
// === inbox 监听 — 任何带 attribution.chatId 的新 inbox item 都自动 register pending feedback
566+
// 用途: 测试脚本 / 第三方触发 cast 不需要走 feishu 消息也能让 bot 反向通道完整 fire
567+
const inboxObserver = (event) => {
568+
event.changes.keys.forEach((change, key) => {
569+
if (change.action !== 'add') return
570+
if (inboxBaseline.has(key)) return
571+
const item = yInbox.get(key)
572+
const chatId = item?.attribution?.chatId
573+
if (!chatId) return
574+
// 已 register 过这条 (避免 status 改 processing 的 update 事件重复入队 — 但 add 不会重入)
575+
log(`[reverse] inbox 新 item key=${key} chatId=${chatId.slice(0, 14)} → register pending`)
576+
registerPendingFeedback(room, {
577+
chatId,
578+
prompt: item.text || '',
579+
sentAt: Number(item.ts) || Date.now(),
580+
})
581+
})
582+
}
583+
yInbox.observe(inboxObserver)
584+
562585
const observer = (event) => {
563586
// 只关心新增 / 字段变化, 不处理删除
564587
event.changes.keys.forEach((change, key) => {
@@ -605,7 +628,7 @@ function ensureReverseChannel(room) {
605628
}
606629
yNodes.observe(observer)
607630

608-
reverseChannels.set(room, { doc, provider, yNodes, observer, baseline, sentConclusions })
631+
reverseChannels.set(room, { doc, provider, yNodes, yInbox, observer, inboxObserver, baseline, inboxBaseline, sentConclusions })
609632
}
610633

611634
// ============== 飞书原生工具 — image upload / docs create / bitable upsert ==============
@@ -1197,6 +1220,14 @@ function start() {
11971220
log(`启动 bot — source-proxy: ${SOURCE_PROXY}, default room: ${DEFAULT_ROOM}, lark-bin: ${LARK_BIN}`)
11981221
log(`事件目录: ${path.resolve(EVENTS_DIR)} (cwd=${process.cwd()})`)
11991222

1223+
// 启动时预热 DEFAULT_ROOM 反向通道 — 这样无 feishu 消息触发的 cast (e2e 测试 / 第三方触发)
1224+
// 也能通过 inbox attribution.chatId 自动 register pending feedback + 等 conclusion 发卡
1225+
try {
1226+
ensureReverseChannel(DEFAULT_ROOM)
1227+
} catch (e) {
1228+
logErr('启动时 ensureReverseChannel 失败 (非致命):', e.message)
1229+
}
1230+
12001231
// 准备事件目录 (清理上次残留, 创建新的)
12011232
try {
12021233
fs.mkdirSync(EVENTS_DIR, { recursive: true })

server/test-full-e2e.mjs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* 完整 E2E 测试: 飞书 prompt → 画布元认知 → bot 反馈卡 + 云文档 + 多维表格 全闭环
3+
*
4+
* 流程:
5+
* 1) 通过 source-proxy /canvas/cast/aletheia-prompt 注入 inbox prompt
6+
* attribution 含 chatId — bot 反向通道见到这个 inbox 自动 register pending
7+
* 2) 等用户浏览器 cc 跑元认知 5 步 (≤ 90s)
8+
* 3) bot 反向通道见到 conclusion → archive (云文档 + Bitable) + sendCard 到飞书
9+
* 4) 验证: bot 日志看到 "[reverse] 发反馈卡" + 拉云文档内容
10+
*
11+
* node server/test-full-e2e.mjs "你的 prompt"
12+
*
13+
* 前置:
14+
* - VPS y-ws (1234) + source-proxy (17090) + bot daemon 都跑着
15+
* - 浏览器至少打开一个 demo-final 房间 (http://66.245.216.250/canvas/?room=demo-final)
16+
*
17+
* 设计要点 (跟 test-archive-only.mjs 区别):
18+
* - 这个脚本不 import feishu-bot 模块, 只发 HTTP cast + 验证 bot 日志
19+
* - 完整覆盖反向通道 — 让 bot 自己跑 archive + sendCard, 比手工 mock 更接近线上行为
20+
*/
21+
import { setTimeout as sleep } from 'node:timers/promises'
22+
23+
const PROXY = process.env.SOURCE_PROXY || 'http://127.0.0.1:17090'
24+
const ROOM = process.env.ROOM || 'demo-final'
25+
const CHAT_ID = process.env.CHAT_ID || 'oc_d2d890f2072a92a98b9f87ccb76a5b68'
26+
const PROMPT = process.argv.slice(2).join(' ').trim() ||
27+
'为知识图谱画布产品做一份 V2 商业化路线规划: 围绕开源/付费定位 / 增值订阅 / 企业版 / 社区运营, 给出具体推进决策'
28+
29+
function logT(...a) {
30+
console.log(`[${new Date().toLocaleTimeString('zh-CN', { hour12: false })}]`, ...a)
31+
}
32+
33+
logT(`====== Aletheia 完整 E2E ======`)
34+
logT(`PROMPT: ${PROMPT}`)
35+
logT(`ROOM: ${ROOM}`)
36+
logT(`CHAT_ID: ${CHAT_ID.slice(0, 14)}...`)
37+
38+
// 步骤 1: cast (attribution 带 chatId, bot inbox 监听器自动 register pending)
39+
logT(`[1/3] cast prompt → ${PROXY}/canvas/cast/aletheia-prompt`)
40+
const castRes = await fetch(`${PROXY}/canvas/cast/aletheia-prompt`, {
41+
method: 'POST',
42+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
43+
body: JSON.stringify({
44+
room: ROOM,
45+
text: PROMPT,
46+
attribution: {
47+
name: '完整 E2E 测试',
48+
via: 'feishu-bot',
49+
chatId: CHAT_ID, // ← 关键: bot 见到这个就 register pending
50+
},
51+
}),
52+
})
53+
const castJson = await castRes.json()
54+
if (!castJson.ok) {
55+
console.error('cast 失败:', castJson)
56+
process.exit(1)
57+
}
58+
logT(`✓ cast inbox=${castJson.id} 在线 cc=${castJson.peers}`)
59+
if (!castJson.peers || castJson.peers === 0) {
60+
logT(`⚠ 0 cc 在线 — 没有执行者, 元认知不会跑. 请确保浏览器打开 ${castJson.canvasUrl}`)
61+
process.exit(2)
62+
}
63+
64+
// 步骤 2: 等元认知完成 + bot 反馈卡发出 (至多 120s)
65+
logT(`[2/3] 等元认知 5 步 + bot archive + sendCard (≤ 120s)...`)
66+
logT(` 在 VPS 上运行: ssh newvps "journalctl -u know-canvas-feishubot -f"`)
67+
logT(` 看到 "[reverse] 发反馈卡" 即完成`)
68+
69+
// 这个脚本本身没法 ssh, 退出后用户用 journalctl 查
70+
// 但作为 e2e 自动化, 至少要 fetch cast 后等 90s 让 bot 跑完
71+
await sleep(90 * 1000)
72+
73+
logT(`[3/3] 90 秒已过 — 检查 bot 日志确认反馈卡已发`)
74+
logT(` 验证: ssh newvps 'journalctl -u know-canvas-feishubot --since "2 minutes ago" | grep -E "reverse|archive"'`)
75+
logT(` 验证: 飞书群 ${CHAT_ID.slice(0, 14)}... 应该收到一张元认知反馈卡`)
76+
logT(`====== E2E 流程已注入 ======`)
77+
process.exit(0)

src/services/aiService.js

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -804,15 +804,28 @@ const ANSWER_HTML_SYSTEM_PROMPT = `你是元认知洞察引擎. 给定用户的
804804
// 决策引擎 — HtmlPageNode 完成后追加一步, 给最终评判
805805
// 输出 verdict (go/hold/pivot) + score + summary + key_insights + improvements + next_steps
806806
// ─────────────────────────────────────────────────────────────────────────────
807-
const DECISION_ENGINE_SYSTEM_PROMPT = `你是 ALETHEIA 决策引擎. 给定一个用户输入和它的元认知/Hermes 产出页面, 给出最终决策评判.
807+
const DECISION_ENGINE_SYSTEM_PROMPT = `你是 ALETHEIA 决策引擎. 给定一个用户输入和它的元认知/Hermes 产出页面, 给出**严格、保守**的决策评判.
808808
809809
判定规则:
810-
- verdict 三选一: 'go' (推荐立即推进) / 'hold' (前置条件 ok 后再推进) / 'pivot' (建议调整方向)
811-
- score: 0-100 整数, 综合可行性 + 信息完备度 + 风险可控性
812-
- summary: 一句决策结论, 30 字内, 直接说"推进/暂缓/转向"和最关键原因
813-
- key_insights: 2-3 条核心洞察, 每条 20 字内, 必须是这次产出新发现的, 不是复读输入
814-
- improvements: 1-3 条具体改进建议, 必须可执行 (不要"加强 xx""完善 yy")
815-
- next_steps: 1-3 条下一步动作, 每条带可量化标准 (24h 内 / 3 城市 / etc)
810+
- verdict 三选一:
811+
'go' = 推荐立即推进 (可行性高, 信息完整, 风险可控)
812+
'hold' = 暂缓, 前置条件 ok 后再推进 (信息缺、有风险或假设未验证)
813+
'pivot' = 建议调整方向 (产出本身路径有偏差, 或回答了错的问题)
814+
815+
- score: 0-100 整数, **保守评分**. 严格遵守区间:
816+
90-100 = 极少给, 几乎所有维度都到位且有明确数据支撑 (含具体数字 / 引用 / 实测)
817+
75-89 = 推荐推进, 但仍能找到 1-2 处可改进
818+
60-74 = 方向对但信息不足或假设多, 建议 hold + 补完
819+
40-59 = 有明显缺口或风险, 必须 pivot 或大改
820+
0-39 = 答非所问 / 几乎无信息密度 / 严重逻辑错
821+
822+
⚠ 默认锚定**不要**给 80+. 没看到 (a) 具体数字/数据 (b) 多场景对比 (c) 风险已识别+有缓解方案 — 三者都到位前不给 80+.
823+
⚠ verdict=hold 时 score 必须 < 75. verdict=pivot 时 score 必须 < 60.
824+
825+
- summary: 一句决策结论, 30 字内, 直接说"推进/暂缓/转向" + 最关键原因 + 最大短板
826+
- key_insights: 2-3 条核心洞察, 每条 20 字内, 必须是这次产出**新发现**的, 不是复读输入
827+
- improvements: 1-3 条具体改进建议, 必须可执行 (不要"加强 xx""完善 yy", 必须 "把 X 替换成 Y" 这种)
828+
- next_steps: 1-3 条下一步动作, 每条带**可量化标准** (例: "24h 内列出 3 个候选库", 不是"调研一下")
816829
817830
输出严格 JSON, 不要 markdown 围栏:
818831
{

src/stores/useCanvasStore.js

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3248,8 +3248,8 @@ ${task.assignee ? `<div class="row"><b>Worker</b><span>${escape(task.assignee)}<
32483248

32493249
// ───── Stage 2 DECOMPOSE (1.2s 后) — 建 task 节点 ─────
32503250
await sleep(1200)
3251-
const COL_W = 260
3252-
const ROW_H = 220
3251+
const COL_W = 280 // task 列宽 (ontologyNode ~220 + 60 gap)
3252+
const ROW_H = 240 // 行高 (ontologyNode ~140 + label/desc 60 + 40 gap)
32533253
const GROUP_CENTER_X = 800 // root 居中在 group 中心
32543254
const ROOT_Y = 60
32553255
const TASK_Y = ROOT_Y + ROW_H
@@ -3316,26 +3316,59 @@ ${task.assignee ? `<div class="row"><b>Worker</b><span>${escape(task.assignee)}<
33163316
})
33173317
})
33183318

3319+
// 改进的 agent 布局: 不按 anchor task 找 x (会重叠), 改全部 agent 一行排开,
3320+
// 按 (stage_index, 同 stage 内顺序) 全局排 — 这样:
3321+
// - 同 stage 的 agent 视觉上仍然相邻 (染色一致)
3322+
// - 不会因为多 role 同 task 而横向只错开 60px (旧逻辑实测重叠严重)
3323+
// - X 间距 = AGENT_COL_W (300), 对 agentRoleNode 宽度 220-260 安全
3324+
const AGENT_COL_W = 300
3325+
const orderedRoles = []
3326+
;[...structure.execution_topology.stages]
3327+
.sort((a, b) => a.stage_index - b.stage_index)
3328+
.forEach((s) => {
3329+
s.role_ids.forEach((rid) => {
3330+
const role = structure.roles.find((r) => r.id === rid)
3331+
if (role) orderedRoles.push({ role, stageIndex: s.stage_index, stageInfo: s })
3332+
})
3333+
})
3334+
// 兜底: 如果有 role 没在 topology 里, 也排进去
3335+
structure.roles.forEach((role) => {
3336+
if (!orderedRoles.find((x) => x.role.id === role.id)) {
3337+
orderedRoles.push({ role, stageIndex: 1, stageInfo: { kind: 'parallel' } })
3338+
}
3339+
})
3340+
const totalAgentCount = orderedRoles.length
3341+
const stageMaxForLayout = Math.max(1, ...orderedRoles.map((r) => r.stageIndex))
3342+
3343+
// 动态扩 projectGroup 宽高 — agent 多 / stage 深时不要溢出 group 边界
3344+
const requiredAgentRowWidth = totalAgentCount * AGENT_COL_W + 200
3345+
const requiredTaskRowWidth = taskCount * COL_W + 200
3346+
const requiredGroupWidth = Math.max(1600, requiredAgentRowWidth, requiredTaskRowWidth)
3347+
// height = root (60+220) + task row (240) + agent_y_offset + conclusion 间距 + 底 padding
3348+
// agent_y_max = TASK_Y + ROW_H + 20 + (stageMax-1)*100 + 140 (agent height)
3349+
// conclusion_y = agent_y_max + 100 (gap) + 140 (conclusion height) + 60 (bottom pad)
3350+
const requiredGroupHeight = TASK_Y + ROW_H + 20 + (stageMaxForLayout - 1) * 100 + 240 + 140 + 60
3351+
const finalGroupHeight = Math.max(1100, requiredGroupHeight)
3352+
if (requiredGroupWidth > 1600 || finalGroupHeight > 1100) {
3353+
set((state) => {
3354+
const g = state.nodes.find((n) => n.id === projectGroupId)
3355+
if (g) {
3356+
g.style = { ...g.style, width: requiredGroupWidth, height: finalGroupHeight }
3357+
}
3358+
})
3359+
}
3360+
const agentStartX = GROUP_CENTER_X - ((totalAgentCount - 1) * AGENT_COL_W) / 2
3361+
33193362
const roleIdMap = {}
3320-
// role 的 y 按它所在 stage_index 错位排, x 跟随 assigned_tasks 第一个 task
3321-
for (let i = 0; i < structure.roles.length; i++) {
3322-
const role = structure.roles[i]
3323-
const firstTaskId = role.assigned_tasks[0]
3324-
const anchorTaskNode = firstTaskId ? taskNodes.find((n) => n.data.projectTaskId === firstTaskId) : null
3325-
const stageInfo = roleStageMap[role.id]
3326-
const stageIndex = stageInfo?.stage_index || 1
3327-
// 同 stage 多 role 横向错开 — 计算这个 stage 里它的位置
3328-
const sameStageRoles = structure.execution_topology.stages.find((s) => s.stage_index === stageIndex)?.role_ids || [role.id]
3329-
const sameStageIdx = sameStageRoles.indexOf(role.id)
3330-
const sameStageCount = sameStageRoles.length
3331-
3332-
// anchorTaskNode.position 已经是相对 projectGroup 的偏移
3333-
const anchorX = anchorTaskNode ? anchorTaskNode.position.x : taskOffsetX0 + i * COL_W
3334-
const xOffset = sameStageCount > 1 ? (sameStageIdx - (sameStageCount - 1) / 2) * 60 : 0
3335-
const x = anchorX + xOffset
3336-
// agent 作为 projectGroup 子, y = task 行下面 + stage 错开
3337-
const AGENT_Y_BASE = TASK_Y + ROW_H
3338-
const y = AGENT_Y_BASE + (stageIndex - 1) * 40
3363+
for (let i = 0; i < orderedRoles.length; i++) {
3364+
const { role, stageIndex, stageInfo } = orderedRoles[i]
3365+
3366+
// x: 全局横向均分, 各 agent 一格 AGENT_COL_W
3367+
const x = agentStartX + i * AGENT_COL_W
3368+
// y: task 行下方 + 按 stage_index 阶梯下沉 (同 stage 同 y, 视觉成行)
3369+
// stage_index 1 → 紧贴 task 行下面; 后续 stage 再下沉一行
3370+
const AGENT_Y_BASE = TASK_Y + ROW_H + 20 // +20 让 agent 离 task 行远一点
3371+
const y = AGENT_Y_BASE + (stageIndex - 1) * 100
33393372

33403373
const nid = `agent-${rootId}-${role.id}`
33413374
roleIdMap[role.id] = nid
@@ -3545,11 +3578,13 @@ ${task.assignee ? `<div class="row"><b>Worker</b><span>${escape(task.assignee)}<
35453578
if (decision) {
35463579
const stageMaxIdx = Math.max(1, ...Object.values(roleStageMap).map((s) => s.stage_index || 1))
35473580
const conclusionId = `conclusion-${rootId}`
3581+
// y = agent 最底行 (TASK_Y+ROW_H+20 + (stageMax-1)*100) + agent 高 ~140 + 间距 100
3582+
const conclusionY = TASK_Y + ROW_H + 20 + (stageMaxIdx - 1) * 100 + 240
35483583
const conclusionNode = {
35493584
id: conclusionId,
35503585
type: 'ontologyNode',
3551-
// 相对 projectGroup: 居中横向, 纵向在所有 agent 之下
3552-
position: { x: GROUP_CENTER_X, y: TASK_Y + ROW_H + (stageMaxIdx - 1) * 40 + 220 },
3586+
// 相对 projectGroup: 居中横向 (GROUP_CENTER_X), 纵向在所有 agent 之下
3587+
position: { x: GROUP_CENTER_X, y: conclusionY },
35533588
parentNode: projectGroupId,
35543589
data: {
35553590
variant: 'goal', // 深色显示

0 commit comments

Comments
 (0)