【安心陪诊 Agent】Node.js 20 + Express 4 实战:Web Demo 与规则 Agent 架构
【安心陪诊 Agent】Node.js 20 + Express 4 实战:Web Demo 与规则 Agent 架构
在陪诊产品中,最容易被误解的需求是“做一个能聊天的 Agent”。真正落地时,用户需要的是一条可执行的任务链:确认出发地和医院、整理材料、生成问诊清单、处理家属同步,并在涉及诊断和处方时明确交给人工。本文用一个 Web Demo 说明 Page、Node.js API 和规则 Agent 如何协作。
编辑
编辑编辑编辑编辑编辑
编辑
编辑编辑编辑
编辑
编辑编辑编辑
编辑编辑编辑编辑
编辑
编辑编辑编辑编辑编辑
编辑
编辑编辑编辑
编辑
编辑编辑编辑
编辑编辑编辑编辑
编辑
编辑编辑编辑编辑编辑
编辑
编辑编辑编辑
编辑
编辑编辑编辑
编辑编辑编辑编辑
一、本文解决什么问题
本文聚焦“自然语言输入如何变成稳定任务卡片”。示例环境为 Node.js 20.11.1、Express 4.18.3、Chrome 126;前端通过结构化 code 判断页面状态,后端不输出诊断结论。
| 层级 | 职责 | 不负责什么 |
|---|---|---|
| Page | 输入、加载、卡片展示 | 不判断医疗结论 |
| API Service | 校验参数、返回协议 | 不保存无关隐私 |
| Rule Agent | 拆解任务、拦截越界问题 | 不替代医生 |
二、目录和启动方式
src/ server.js routes/plan.js services/plan-service.js rules/safety-rule.js public/index.html node --version npm list express npm run dev编辑
编辑编辑编辑
编辑编辑编辑
编辑编辑先固定 Node.js 和 Express 版本,再启动本地服务。这样读者遇到请求体解析或路由行为差异时,可以先排除运行时版本问题。
三、接口协议:先定义状态再写页面
import express from "express"; const app = express(); app.use(express.json()); app.post("/api/plan", (req, res) => { const { start, hospital, visitTime } = req.body || {}; if (!start || !hospital) { return res.status(400).json({ code: "MISSING_ROUTE", fields: ["start", "hospital"], message: "请补充出发地和医院" }); } return res.json({ code: "OK", route: { start, hospital, visitTime: visitTime || "待确认" }, tasks: ["材料清单", "问诊问题", "家属同步"] }); });编辑
编辑编辑编辑
编辑编辑编辑
编辑编辑返回值使用 code、route 和 tasks 三个稳定字段。前端不需要猜测自然语言,也能对正常、缺字段和安全拦截分别渲染。
四、规则 Agent 的安全边界
const blocked = ["诊断", "处方", "剂量", "停药"]; function guard(text) { const hit = blocked.find((word) => String(text || "").includes(word)); return hit ? { code: "SAFE_GUARD", hit, next: "人工确认" } : { code: "OK" }; }编辑
编辑编辑编辑
编辑编辑编辑
编辑编辑规则只负责发现风险并给出下一步,不输出病情判断。这个边界既能减少误导,也让测试用例有明确的预期。
五、前端如何消费返回值
async function submitPlan(form) { const response = await fetch("/api/plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) }); const data = await response.json(); if (data.code === "MISSING_ROUTE") return showFormError(data.message); if (data.code === "SAFE_GUARD") return showSafetyCard(data.next); return renderTaskCards(data.tasks); }编辑
编辑编辑编辑
编辑编辑编辑
编辑编辑页面只根据协议渲染状态:错误提示可修复,安全提示需要人工确认,成功状态展示任务卡片。网络失败时还要保留重试按钮和当前输入。
六、可复现请求和返回
curl -X POST http://localhost:5188/api/plan ^ -H "Content-Type: application/json" ^ -d "{"start":"杭州西湖文化广场","hospital":"浙江省人民医院","visitTime":"周三上午"}" { "code": "OK", "tasks": ["材料清单", "问诊问题", "家属同步"] }编辑
编辑编辑编辑
编辑编辑编辑
编辑编辑七、验收清单
| 场景 | 输入 | 预期 | 结果 |
|---|---|---|---|
| 正常规划 | 出发地、医院、时间 | 200、OK、任务非空 | 通过 |
| 缺少医院 | hospital 为空 | 400、MISSING_ROUTE | 通过 |
| 风险问题 | 包含诊断或剂量 | SAFE_GUARD、人工确认 | 通过 |
| 网络失败 | 服务停止 | 显示重试,不丢输入 | 通过 |
| 移动窗口 | 手机宽度打开页面 | 卡片不遮挡按钮 | 通过 |
八、常见问题
| 问题 | 原因 | 处理 |
|---|---|---|
| 页面只显示一段文本 | 前端没有按 code 分支 | 统一消费结构化返回 |
| 接口返回 400 | 缺少必填字段 | 检查 start 和 hospital |
| 安全提示不出现 | 规则没有在入口执行 | 在 /api/chat 和 /api/plan 前置 guard |
九、总结
安心陪诊 Agent 的工程价值不在于“什么都能聊”,而在于把就医准备拆成可验证任务,并对医疗风险保持克制。固定版本、明确协议、保留异常路径和真实验收记录,才能让 Web Demo 具备迁移到 HarmonyOS ArkTS 的基础。
扩展验证:从 Web Demo 迁移到 HarmonyOS
Web Demo 的协议不应该和页面组件绑死。迁移到 HarmonyOS ArkTS 时,可以把 PlanResult、TaskItem 和 SafetyNotice 作为公共模型,页面只负责状态渲染,网络访问放在 Service,Preferences 只保存用户主动勾选的轻量设置。
interface TaskItem { title: string; detail: string; done: boolean; } interface PlanResult { code: "OK" | "MISSING_ROUTE" | "SAFE_GUARD"; message?: string; tasks: TaskItem[]; } function canRender(result: PlanResult): boolean { return result.code === "OK" && result.tasks.length > 0; }编辑
编辑编辑编辑
编辑编辑编辑
编辑编辑这样迁移时,Web 的返回值仍然可以驱动原生任务卡片;网络失败、空数据和安全拦截也能保持一致,不需要在每个页面重复判断字符串。
异常路径和重试策略
| 异常 | 用户看到的状态 | 下一步 |
|---|---|---|
| 网络超时 | 服务暂时不可用 | 保留输入并重试 |
| 接口 400 | 指出缺少的字段 | 回到表单定位修复 |
| 返回数据为空 | 暂无可生成任务 | 允许重新描述需求 |
| 命中医疗风险 | 需要人工确认 | 不输出诊断和剂量 |
重试不能无限循环。前端最多自动重试一次,之后显示明确按钮;服务端记录请求耗时和错误码,但不记录不必要的病情细节和家属联系方式。
测试矩阵
const cases = [ { name: "normal", body: { start: "杭州", hospital: "省人民医院" }, expect: "OK" }, { name: "missing-hospital", body: { start: "杭州" }, expect: "MISSING_ROUTE" }, { name: "medical-risk", body: { start: "杭州", hospital: "省人民医院", text: "如何调整剂量" }, expect: "SAFE_GUARD" } ];编辑
编辑编辑编辑
编辑编辑编辑
编辑编辑测试名称直接对应接口返回码,验收人员可以先跑接口,再打开页面核对状态。这样问题能定位到协议、规则还是 UI,而不是只记录“按钮点过了”。
发布前复查
| 项目 | 检查方式 | 通过标准 |
|---|---|---|
| 版本 | node --version、npm list express | Node 20.11.1、Express 4.18.3 |
| 接口 | curl 调用 /api/plan | 成功与错误码都可复现 |
| 图片 | 列表页和正文预览 | 封面、流程和项目截图清晰 |
| 隐私 | 检查日志和请求体 | 不上传无关个人信息 |
| 移动端 | Chrome 126 缩小窗口 | 按钮、卡片和错误提示可见 |
完整的陪诊 Agent 不是一个泛聊天框,而是一个可解释、可验证、可回退的任务系统。版本、协议、代码和测试记录彼此对应,文章才真正能帮助读者复现。
可运行接口:规则 Agent 如何接收、校验并返回任务
以下示例来自 Demo 的 Node.js 20.11.1 与 Express 4.18.3 服务层。它只把用户已经确认的陪诊事项整理为待办,不推断病情,也不生成诊疗建议。请求体在进入规则层前完成字段校验,便于本地复现。
import express from 'express'; const app = express(); app.use(express.json()); app.post('/api/escort-plan', (req, res) => { const { hospital, visitAt, need } = req.body ?? {}; if (![hospital, visitAt, need].every((value) => typeof value === 'string' && value.trim())) { return res.status(400).json({ code: 'INVALID_ARGUMENT', message: 'hospital、visitAt、need 为必填文本' }); } const tasks = [ `提前确认 ${hospital} 的院区和到达时间`, `准备证件、既往检查资料和问题清单`, `在 ${visitAt} 前预留出行与签到时间` ]; return res.json({ code: 'OK', version: 'demo-1.0.0', tasks, boundary: 'not-medical-diagnosis' }); }); app.listen(3000);编辑
编辑编辑本地验证命令:node server.mjs后执行curl -X POST http://127.0.0.1:3000/api/escort-plan -H "Content-Type: application/json" -d "{\"hospital\":\"门诊楼\",\"visitAt\":\"09:00\",\"need\":\"协助取号\"}"。预期得到 200、三个待办和not-medical-diagnosis边界标记;缺失字段时返回 400。
| 验证项 | 输入 | 预期结果 |
|---|---|---|
| 正常任务 | 院区、时间、需求齐全 | 200,返回 3 条可执行待办 |
| 字段缺失 | visitAt 为空 | 400,不生成任务 |
| 医疗边界 | 症状或用药问题 | 提示联系专业医疗人员,不作诊断 |
完整接口契约:任务生成不是一句提示词
在 Demo 里,规则 Agent 的输出要先定义成稳定的数据协议,再交给页面渲染。否则同一个“帮我准备陪诊”的输入,今天可能返回字符串,明天变成数组,前端会不断增加临时判断。这里的协议只描述流程事项和展示状态,明确不携带病情判断、药品剂量或处方内容。
export type TaskStatus = 'todo' | 'done' | 'blocked'; export interface EscortTask { id: string; title: string; status: TaskStatus; source: 'rule'; } export interface EscortPlanResponse { code: 'OK' | 'INVALID_ARGUMENT' | 'OUT_OF_SCOPE'; message: string; tasks: EscortTask[]; traceId: string; } export function toPlanResponse(input: { hospital: string; visitAt: string; need: string }): EscortPlanResponse { const traceId = crypto.randomUUID(); if (![input.hospital, input.visitAt, input.need].every((value) => value.trim())) { return { code: 'INVALID_ARGUMENT', message: '请补全医院、时间和陪诊需求', tasks: [], traceId }; } if (/处方|剂量|确诊|诊断/.test(input.need)) { return { code: 'OUT_OF_SCOPE', message: '该问题需要由医生或药师处理', tasks: [], traceId }; } return { code: 'OK', message: '已生成就诊准备清单', traceId, tasks: [ { id: 'arrival', title: `确认 ${input.hospital} 的院区与到达路线`, status: 'todo', source: 'rule' }, { id: 'materials', title: '准备证件、病历和检查资料', status: 'todo', source: 'rule' }, { id: 'time', title: `在 ${input.visitAt} 前预留签到时间`, status: 'todo', source: 'rule' } ] }; }页面只根据code决定状态:OK展示任务卡片,INVALID_ARGUMENT高亮未完成字段,OUT_OF_SCOPE显示医疗边界说明。这样 UI 不解析自然语言,也不会把错误信息伪装成成功结果。
HTTP 路由、超时与可观察性
服务端在 Node.js 20.11.1 和 Express 4.18.3 下运行。每次请求分配 traceId,日志只记录流程字段和结果码,不记录身份证号、病历图片或具体病情描述。前端超时后保留用户输入并允许再次提交。
app.post('/api/escort-plan', (req, res) => { const startedAt = Date.now(); const result = toPlanResponse(req.body ?? {}); console.info(JSON.stringify({ event: 'escort_plan', traceId: result.traceId, code: result.code, durationMs: Date.now() - startedAt })); const status = result.code === 'OK' ? 200 : result.code === 'INVALID_ARGUMENT' ? 400 : 422; res.status(status).json(result); }); app.use((error, _req, res, _next) => { console.error('unexpected_error', error?.message); res.status(500).json({ code: 'SERVER_ERROR', message: '服务暂不可用,请稍后重试', tasks: [] }); });本地运行命令为npm ci、npm run dev。使用 Chrome 126 打开页面后,在 Network 面板中可核对请求体、状态码和 traceId;调用失败时页面展示重试按钮而不是清空已填写的医院与时间。
从接口到页面的状态表
| 接口返回 | 页面表现 | 用户下一步 | 日志字段 |
|---|---|---|---|
| 200 / OK | 显示 3 个待办卡片 | 勾选已准备事项 | traceId、durationMs、OK |
| 400 / INVALID_ARGUMENT | 定位缺失字段 | 补全后再次生成 | traceId、INVALID_ARGUMENT |
| 422 / OUT_OF_SCOPE | 显示医疗边界提示 | 联系医生或药师 | traceId、OUT_OF_SCOPE |
| 500 / SERVER_ERROR | 保留输入和重试入口 | 稍后重试 | traceId、SERVER_ERROR |
可复现的测试与验收记录
import assert from 'node:assert/strict'; const normal = toPlanResponse({ hospital: '门诊楼', visitAt: '09:00', need: '协助取号' }); assert.equal(normal.code, 'OK'); assert.equal(normal.tasks.length, 3); const missing = toPlanResponse({ hospital: '', visitAt: '09:00', need: '协助取号' }); assert.equal(missing.code, 'INVALID_ARGUMENT'); const boundary = toPlanResponse({ hospital: '门诊楼', visitAt: '09:00', need: '这个药吃多少' }); assert.equal(boundary.code, 'OUT_OF_SCOPE');执行node --test test/escort-plan.test.js,三条断言应全部通过。最后再进行一轮人工验收:窄屏 360px、普通桌面 1280px、请求超时、重复点击提交、医疗边界输入。每一项都应有 loading、success、error 或 disabled 的可见状态。
| 验收项 | 操作 | 通过标准 |
|---|---|---|
| 正常生成 | 填入医院、时间、取号需求 | 返回三条任务并可勾选 |
| 重复提交 | 连续点击两次生成 | 按钮 loading,只有一次结果 |
| 断网 | 浏览器离线后提交 | 输入保留并显示重试 |
| 边界输入 | 输入诊断/剂量问题 | 不生成医疗建议 |
| 小屏 | 宽度 360px | 任务卡和按钮不溢出 |
前端状态机:防止重复提交与结果闪烁
Web Demo 的交互状态需要独立于接口返回管理。用户点击“生成陪诊清单”后,按钮先进入 loading;接口成功才进入 success;网络异常进入 error;在 loading 期间禁用再次点击。这样即使网络抖动,也不会出现两次请求覆盖同一组任务的情况。
const state = { phase: 'idle', error: '', plan: null }; function renderPlanState() { submitButton.disabled = state.phase === 'loading'; submitButton.textContent = state.phase === 'loading' ? '正在生成...' : '生成陪诊清单'; errorBox.hidden = state.phase !== 'error'; errorBox.textContent = state.error; resultPanel.hidden = state.phase !== 'success'; } async function submitPlan(payload) { if (state.phase === 'loading') return; state.phase = 'loading'; state.error = ''; renderPlanState(); try { const response = await fetch('/api/escort-plan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await response.json(); if (!response.ok) throw new Error(data.message || '请求失败'); state.plan = data; state.phase = 'success'; } catch (error) { state.phase = 'error'; state.error = String(error.message || error); } renderPlanState(); }可访问性也要在 Demo 中可见:错误区域使用role="alert",loading 状态使用aria-busy="true",任务勾选框必须有文字标签。键盘使用 Tab 可以依次到达医院、时间、需求、提交按钮和重试按钮,焦点不会落到被隐藏的结果面板上。
| 交互场景 | 检查动作 | 预期反馈 |
|---|---|---|
| 网络慢 | 模拟 3 秒响应 | 按钮禁用且显示正在生成 |
| 连续点击 | 快速点击提交两次 | 只发送一次请求 |
| 请求失败 | 返回 500 | 保留表单并显示重试 |
| 键盘操作 | 仅使用 Tab 与 Enter | 完整走通提交流程 |
完成上述验证后,Demo 的价值不再只是“能跑起来”:它有明确输入、稳定接口、可见状态、异常兜底和可被他人复查的测试路径。这些内容也方便后续迁移到 HarmonyOS ArkUI 页面,由页面状态、服务层和本地记录共同承担任务闭环。
延伸阅读
这篇文章和同项目的实现、测试或排错文章可以组合阅读,下面两篇给出相邻的工程上下文。
- 查看本系列的相关实践 1
- 查看本系列的相关实践 2