From 223e0ed2eeb646026ab5e0d46aa889f5599143d1 Mon Sep 17 00:00:00 2001 From: lik Date: Wed, 9 Sep 2026 17:36:45 +0800 Subject: [PATCH] tmp --- agent/bots/bot-escort-admin/agent.js | 36 ++--- agent/bots/bot-escort-admin/prompts.js | 4 +- agent/bots/bot-escort/AGENTS.md | 6 +- agent/bots/bot-escort/agent.js | 27 ++-- agent/bots/bot-escort/prompts.js | 11 +- agent/infra/restricted_fs_backend.js | 96 +++++++++++++ agent/infra/test_restricted_backend.mjs | 58 ++++++++ agent/infra/tools/db/health_profile_query.js | 85 +++++++++++ agent/infra/tools/db/health_profile_set.js | 140 ++++++++++++++++++ agent/infra/tools/db/hospital_info_get.js | 32 +++++ agent/infra/tools/db/hospital_info_list.js | 38 +++++ agent/infra/tools/db/hospital_info_query.js | 63 ++++++++ agent/infra/tools/db/hospital_info_set.js | 143 +++++++++++++++++++ agent/infra/tools/index.js | 7 + agent/subagent/hospital_info.js | 28 ++++ agent/subagent/patient_record.js | 24 ++++ agent/subagent/research.js | 10 ++ conf.json | 1 + config.js | 0 models/schema/employee.js | 41 +++++- models/schema/health_profile.js | 52 ++++++- models/schema/organization.js | 72 +++++++++- package-lock.json | 13 +- package.json | 1 + websocket.js | 4 +- 25 files changed, 933 insertions(+), 59 deletions(-) create mode 100644 agent/infra/restricted_fs_backend.js create mode 100644 agent/infra/test_restricted_backend.mjs create mode 100644 agent/infra/tools/db/health_profile_query.js create mode 100644 agent/infra/tools/db/health_profile_set.js create mode 100644 agent/infra/tools/db/hospital_info_get.js create mode 100644 agent/infra/tools/db/hospital_info_list.js create mode 100644 agent/infra/tools/db/hospital_info_query.js create mode 100644 agent/infra/tools/db/hospital_info_set.js create mode 100644 agent/subagent/hospital_info.js create mode 100644 agent/subagent/patient_record.js create mode 100644 agent/subagent/research.js create mode 100644 config.js diff --git a/agent/bots/bot-escort-admin/agent.js b/agent/bots/bot-escort-admin/agent.js index 2607066..bf21664 100644 --- a/agent/bots/bot-escort-admin/agent.js +++ b/agent/bots/bot-escort-admin/agent.js @@ -1,20 +1,24 @@ import 'dotenv/config'; -import { createDeepAgent, FilesystemBackend } from "deepagents"; -import { AIMessageChunk, ToolMessage } from "langchain"; +import { createDeepAgent } from "deepagents"; +import { AIMessageChunk, ToolMessage, todoListMiddleware } from "langchain"; import { HumanMessage } from "@langchain/core/messages"; import { ChatDeepSeek } from "@langchain/deepseek"; -import config from '../../conf.json' with { type: 'json' }; -import logger from '../../utils/logger.js'; +import config from '../../../conf.json' with { type: 'json' }; +import logger from '../../../utils/logger.js'; +import { createRestrictedBackend } from "../../infra/restricted_fs_backend.js"; // 密钥由 conf.json 统一提供;百度地图 skill 通过 env_get 读环境变量,此处桥接 process.env.TAVILY_API_KEY ??= config.agent.tavily?.apiKey; process.env.BAIDU_MAP_AUTH_TOKEN ??= config.agent.baiduMap?.authToken; import EscortAdminPrompts from "./prompts.js"; +import { createPatientRecordSubagent } from "../../subagent/patient_record.js"; +import { createResearchSubagent } from "../../subagent/research.js"; +import { createHospitalInfoSubagent } from "../../subagent/hospital_info.js"; import { getEnvTool, webFetchTool, webSearchTool, getCalendarInfoTool, getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool, getLatLngTool, httpGetTool, httpPostTool, escortRecordQueryTool, escortRecordSetTool -} from "./tools/index.js"; +} from "../../infra/tools/index.js"; export default class EscortAdminAgent { constructor() { @@ -126,7 +130,8 @@ export default class EscortAdminAgent { _genAgent(userInfo) { const rootDir = process.cwd(); - const backend = new FilesystemBackend({ rootDir }); + // 受限后端:锁定沙箱根并禁止读取 conf.json/.env/logs 及用户数据目录 + const backend = createRestrictedBackend({ rootDir, dataDir: config.agent.dataDir }); this.flashModel = new ChatDeepSeek({ model: config.agent.deepseek.flashModel, @@ -139,31 +144,16 @@ export default class EscortAdminAgent { temperature: 0.3 }); - const escortRecordOperSubagent = { - name: "escort-record-oper-subagent", - description: "查询和设置陪诊预约记录", - systemPrompt: "根据用户指令,调用工具完成查询和设置陪诊记录。", - model: this.flashModel, - tools: [escortRecordQueryTool, escortRecordSetTool], - }; - - const escortResearchSubagent = { - name: "escort-research-subagent", - description: "陪诊(陪同就医)行业问题研究和解答", - systemPrompt: "你是陪诊(陪同就医)行业政策、发展趋势、行业知识研究和解答专家。", - model: this.proModel, - tools: [webFetchTool, webSearchTool], - }; - return createDeepAgent({ name: "deep-agent", model: this.flashModel, systemPrompt: EscortAdminPrompts.buildSystemPrompt(userInfo), backend, + middleware: [todoListMiddleware()], // deepagents 1.12 起 todo 计划工具改为显式开启,多步记录操作用 tools: [getEnvTool, webFetchTool, webSearchTool, getLatLngTool, httpGetTool, httpPostTool, getCalendarInfoTool, getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool, escortRecordQueryTool, escortRecordSetTool], - subagents: [escortRecordOperSubagent, escortResearchSubagent] + subagents: [createPatientRecordSubagent(this.flashModel), createResearchSubagent(this.proModel), createHospitalInfoSubagent(this.flashModel)] }); } } diff --git a/agent/bots/bot-escort-admin/prompts.js b/agent/bots/bot-escort-admin/prompts.js index e824f2d..dca22ea 100644 --- a/agent/bots/bot-escort-admin/prompts.js +++ b/agent/bots/bot-escort-admin/prompts.js @@ -1,5 +1,5 @@ -import services from "../../resource/services.js"; -import agreement from "../../resource/agreement.js"; +import services from "../../../resource/services.js"; +import agreement from "../../../resource/agreement.js"; class EscortAdminPrompts { static buildSystemPrompt(userInfo) { diff --git a/agent/bots/bot-escort/AGENTS.md b/agent/bots/bot-escort/AGENTS.md index 64284b4..067127c 100644 --- a/agent/bots/bot-escort/AGENTS.md +++ b/agent/bots/bot-escort/AGENTS.md @@ -4,7 +4,7 @@ `/memories/` 目录用于持久化存储用户信息,在每次会话开始时自动加载。 ## 长期记忆 -当用户分享以下信息时,使用 `write_file` 将其保存到 `/memories/user_memory.txt`: +当用户分享以下信息时,使用 `write_file` 将其保存到 `/memories/memory.txt`: - 个人基本信息、生活习惯、个人喜好 - 健康或医疗相关的任何信息(身体健康、看病、住院、手术、病情、用药、过敏、体质、病历、检查报告、长期健康目标等) - 医疗信息要记录对应的日期时间,如果用户没有提供具体的,要根据前后信息记录大概时间。 @@ -12,5 +12,5 @@ ## 维护规范 - 文件内容使用 UTF-8 编码 -- 每次写入时,将新内容与已有记忆合并整理后再保存 -- 为保护用户隐私,除非用户询问自己的健康或医疗相关资料,否则/memories/user_memory.txt里的信息不轻易输出给用户。 \ No newline at end of file +- 每次保存时,先把新信息与已有记忆整理成完整的记忆内容,再用 `write_file` 一次性整体重写 `/memories/memory.txt`(write_file 支持整文件覆盖,无需先读后改或多次编辑) +- 为保护用户隐私,除非用户询问自己的健康或医疗相关资料,否则/memories/memory.txt里的信息不轻易输出给用户。 \ No newline at end of file diff --git a/agent/bots/bot-escort/agent.js b/agent/bots/bot-escort/agent.js index 1e3bc83..79e2c3f 100644 --- a/agent/bots/bot-escort/agent.js +++ b/agent/bots/bot-escort/agent.js @@ -6,18 +6,20 @@ import { ChatOpenAI } from "@langchain/openai"; import { AIMessageChunk, ToolMessage } from "langchain"; import { HumanMessage } from "@langchain/core/messages"; import { ChatDeepSeek } from "@langchain/deepseek"; -import config from '../../conf.json' with { type: 'json' }; -import logger from '../../utils/logger.js'; +import config from '../../../conf.json' with { type: 'json' }; +import logger from '../../../utils/logger.js'; // 密钥由 conf.json 统一提供;百度地图 skill 通过 env_get 读环境变量,此处桥接 process.env.TAVILY_API_KEY ??= config.agent.tavily?.apiKey; process.env.BAIDU_MAP_AUTH_TOKEN ??= config.agent.baiduMap?.authToken; import Prompts from "./prompts.js"; +import { createRestrictedBackend } from "../../infra/restricted_fs_backend.js"; +import { createHospitalInfoSubagent } from "../../subagent/hospital_info.js"; import { getEnvTool, webFetchTool, webSearchTool, getCalendarInfoTool, getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool, getLatLngTool, httpGetTool, httpPostTool, createEscortRecordQueryTool -} from "./tools/index.js"; +} from "../../infra/tools/index.js"; export default class EscortAgent { // 历史消息字符数上限,超出时从头部丢弃完整轮次 @@ -144,8 +146,10 @@ export default class EscortAgent { _genAgent(userInfo) { const rootDir = process.cwd(); + // 外部数据(用户记忆等)存储目录,由 conf.json agent.dataDir 配置 + const dataDir = path.resolve(rootDir, config.agent.dataDir ?? "data"); const memoryFile = userInfo - ? path.join(rootDir, "data", userInfo._id, "memories", "user_memory.txt") + ? path.join(dataDir, "users", userInfo._id, "memory.txt") : null; // 会话中记忆文件被更新后重建 agent,使 systemPrompt 中的用户记忆保持最新 @@ -156,15 +160,17 @@ export default class EscortAgent { return this.agent; } - let backend = new FilesystemBackend({ rootDir }); + // 受限后端:锁定沙箱根并禁止读取 conf.json/.env/logs 及用户数据目录 + let backend = createRestrictedBackend({ rootDir, dataDir }); if (userInfo) { - const userMemoryPath = path.join(rootDir, "data", userInfo._id, "memories"); + // 挂载根为用户数据目录:/memories/memory.txt ↔ data/users/{userId}/memory.txt + const userMemoryRoot = path.join(dataDir, "users", userInfo._id); backend = new CompositeBackend( - new FilesystemBackend({ rootDir }), + createRestrictedBackend({ rootDir, dataDir }), { "/memories/": new FilesystemBackend({ - rootDir: userMemoryPath, + rootDir: userMemoryRoot, virtualMode: true }) }, @@ -183,12 +189,13 @@ export default class EscortAgent { name: "deep-agent", model: this.flashModel, systemPrompt: Prompts.buildSystemPrompt(userInfo), - memory: ["./agent/escort/AGENTS.md"], + memory: ["./agent/bots/bot-escort/AGENTS.md"], backend, tools: [getEnvTool, webFetchTool, webSearchTool, getLatLngTool, httpGetTool, httpPostTool, getCalendarInfoTool, getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool, createEscortRecordQueryTool(userInfo)], - skills: ["./agent/escort/skills/"], + subagents: [createHospitalInfoSubagent(this.flashModel)], + skills: ["./agent/infra/skills/"], }); return this.agent; diff --git a/agent/bots/bot-escort/prompts.js b/agent/bots/bot-escort/prompts.js index 2d6b304..a94cddf 100644 --- a/agent/bots/bot-escort/prompts.js +++ b/agent/bots/bot-escort/prompts.js @@ -1,8 +1,9 @@ import fs from "fs"; import path from "path"; -import services from "../../resource/services.js"; -import agreement from "../../resource/agreement.js"; -import logger from "../../utils/logger.js"; +import config from "../../../conf.json" with { type: 'json' }; +import services from "../../../resource/services.js"; +import agreement from "../../../resource/agreement.js"; +import logger from "../../../utils/logger.js"; class Prompts { static buildSystemPrompt(userInfo) { @@ -19,8 +20,8 @@ class Prompts { address: userInfo.addresses || [], }); - const rootDir = process.cwd(); - const userMemoryPath = path.join(rootDir, "data", userInfo._id, "memories", "user_memory.txt"); + const dataDir = path.resolve(process.cwd(), config.agent.dataDir ?? "data"); + const userMemoryPath = path.join(dataDir, "users", userInfo._id, "memory.txt"); try { usermem_str = fs.readFileSync(userMemoryPath, 'utf8'); } catch (err) { diff --git a/agent/infra/restricted_fs_backend.js b/agent/infra/restricted_fs_backend.js new file mode 100644 index 0000000..70b539e --- /dev/null +++ b/agent/infra/restricted_fs_backend.js @@ -0,0 +1,96 @@ +import path from "path"; +import { FilesystemBackend } from "deepagents"; + +/** + * 受限文件系统后端:在 FilesystemBackend 基础上收紧 LLM 文件工具的访问范围。 + * + * 相比原生非 virtualMode 模式的两个安全缺口: + * 1. 原生 resolvePath 对绝对路径原样放行(可读磁盘任意文件),此处强制锁定在 rootDir 内; + * 2. 沙箱根下的敏感文件(conf.json、.env*、logs、dataDir)默认禁止访问, + * 防止 LLM 通过 read_file/grep 读取密钥、数据库连接串及其他用户记忆。 + * + * 注意:memory(AGENTS.md)与 skills(SKILL.md)也经由 backend 加载, + * 均不在 deny 列表中,不受影响。 + * + * 注:1.11+ 的 createDeepAgent permissions 机制对真实磁盘后端不适用—— + * 非虚拟模式下 ls/glob/grep 结果是 Windows 绝对路径,而权限规则强制以 "/" 开头, + * 永远匹配不上(grep 会泄露 conf.json 内容)。敏感路径拦截必须保持在后端层实现。 + */ +export class RestrictedFilesystemBackend extends FilesystemBackend { + constructor(options = {}) { + super(options); + this.denyPaths = (options.deny ?? ["conf.json", "logs"]).map(d => String(d).toLowerCase().replace(/\/+$/, "")); + } + + // 判断已解析的绝对路径是否命中 deny 列表(目录条目连同其子路径一并命中) + _isDenied(fullPath) { + const rel = path.relative(this.cwd, fullPath); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false; + const norm = rel.split(path.sep).join("/").toLowerCase(); + // 根目录下的 .env / .env.local 等 + if (norm === ".env" || norm.startsWith(".env.")) return true; + return this.denyPaths.some(d => norm === d || norm.startsWith(d + "/")); + } + + /** + * 覆写路径解析:一律锁定在 rootDir 内。 + * - "/" 或空串视为沙箱根(LLM 工具以 / 为根) + * - 相对路径按 rootDir 解析 + * - 绝对路径仅接受位于 rootDir 内的(非 virtualMode 的 ls/glob 返回的是绝对路径,需支持回传) + * - 禁止 .. 与 ~ 穿越 + * - 命中 deny 列表直接抛错 + */ + resolvePath(key) { + const k = String(key); + if (k.includes("..") || k.startsWith("~")) { + throw new Error(`Path traversal not allowed: ${key}`); + } + let full; + if (/^\/+$/.test(k)) { + full = this.cwd; + } else if (path.isAbsolute(k)) { + full = path.resolve(k); + } else { + full = path.resolve(this.cwd, k); + } + const rel = path.relative(this.cwd, full); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + throw new Error(`Path outside sandbox root: ${key}`); + } + if (this._isDenied(full)) { + throw new Error(`Access denied: ${key}`); + } + return full; + } + + // ls/glob/grep 需过滤结果,避免泄露敏感文件的存在性与内容 + async ls(dirPath) { + const result = await super.ls(dirPath); + return { files: (result.files ?? []).filter(f => !this._isDenied(f.path)) }; + } + + async glob(pattern, searchPath = "/") { + const result = await super.glob(pattern, searchPath); + return { files: (result.files ?? []).filter(f => !this._isDenied(f.path)) }; + } + + async grep(pattern, dirPath = "/", globFilter = null, maxCount = null) { + const result = await super.grep(pattern, dirPath, globFilter, maxCount); + return { ...result, matches: (result.matches ?? []).filter(m => !this._isDenied(m.path)) }; + } +} + +/** + * 创建 bot 专用的受限后端,自动把 dataDir(conf.json agent.dataDir)加入 deny 列表。 + * dataDir 位于沙箱根之外时无需 deny(沙箱本身不可达)。 + */ +export function createRestrictedBackend({ rootDir, dataDir }) { + const deny = ["conf.json", "logs"]; + if (dataDir) { + const rel = path.relative(path.resolve(rootDir), path.resolve(dataDir)); + if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) { + deny.push(rel.split(path.sep).join("/")); + } + } + return new RestrictedFilesystemBackend({ rootDir, deny }); +} diff --git a/agent/infra/test_restricted_backend.mjs b/agent/infra/test_restricted_backend.mjs new file mode 100644 index 0000000..0e40373 --- /dev/null +++ b/agent/infra/test_restricted_backend.mjs @@ -0,0 +1,58 @@ +// 受限后端安全测试:验证敏感路径被拦截、正常资源可访问 +import { createRestrictedBackend } from "./restricted_fs_backend.js"; +import { CompositeBackend, FilesystemBackend } from "deepagents"; + +const ROOT = "d:/work/ws_health/api/health"; +const b = createRestrictedBackend({ rootDir: ROOT, dataDir: "./data" }); + +let pass = 0, fail = 0; +const check = (name, cond) => { cond ? pass++ : fail++; console.log(`${cond ? "PASS" : "FAIL"} ${name}`); }; + +// 1. 敏感文件读取拦截 +check("read conf.json (relative) denied", (await b.read("conf.json")).error?.includes("denied")); +check("read conf.json (absolute) denied", (await b.read("d:/work/ws_health/api/health/conf.json")).error?.includes("denied")); +check("read /conf.json (root-absolute) denied", (await b.read("/conf.json")).error !== undefined); +check("read .env denied", (await b.read(".env")).error?.includes("denied")); +check("read data dir file denied", (await b.read("data/users/u123/memory.txt")).error?.includes("denied")); +check("read logs denied", (await b.read("logs/x.log")).error?.includes("denied")); + +// 2. 穿越与沙箱外路径拦截 +check("traversal denied", (await b.read("../conf.json")).error !== undefined); +check("outside sandbox denied", (await b.read("c:/Windows/win.ini")).error !== undefined); + +// 3. 正常资源仍可访问 +const agents = await b.read("agent/bots/bot-escort/AGENTS.md"); +check("read AGENTS.md ok", !agents.error && agents.content.includes("小橙") === false || !agents.error); +const skill = await b.read("agent/infra/skills/baidu-ai-map/SKILL.md"); +check("read SKILL.md ok", !skill.error); + +// 4. ls / glob / grep 过滤 +const ls = await b.ls("/"); +check("ls no conf.json", !ls.files.some(f => f.path.toLowerCase().endsWith("conf.json"))); +const glob = await b.glob("**/conf*.json"); +check("glob no conf.json", !glob.files.some(f => f.path.toLowerCase().endsWith("conf.json"))); +const grep1 = await b.grep("apiKey", "/"); +check("grep no conf.json match", !grep1.matches.some(m => m.path.toLowerCase().endsWith("conf.json"))); +const grep2 = await b.grep("escort", "agent/bots/bot-escort"); +check("grep normal dir works", grep2.matches.length > 0); + +// 5. 拒绝写敏感文件、允许写普通目录 +check("write conf.json denied", (await b.write("conf.json", "{}")).error?.includes("denied")); +const w = await b.write("data-sandbox-test.txt", "hello"); +check("write normal file ok", !w.error); +await b.delete?.("data-sandbox-test.txt"); + +// 6. /memories/ 虚拟挂载( escort 场景)不受影响 +const composite = new CompositeBackend( + createRestrictedBackend({ rootDir: ROOT, dataDir: "./data" }), + { "/memories/": new FilesystemBackend({ rootDir: ROOT + "/data/users/u123", virtualMode: true }) }, +); +const wm = await composite.write("/memories/memory.txt", "测试记忆"); +check("write /memories ok", !wm.error); +const rm = await composite.read("/memories/memory.txt"); +check("read /memories ok", !rm.error && rm.content === "测试记忆"); +const wm2 = await composite.read("/conf.json"); +check("composite /conf.json denied", wm2.error?.includes("denied") || wm2.error !== undefined); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/agent/infra/tools/db/health_profile_query.js b/agent/infra/tools/db/health_profile_query.js new file mode 100644 index 0000000..715e52a --- /dev/null +++ b/agent/infra/tools/db/health_profile_query.js @@ -0,0 +1,85 @@ +import { tool } from "@langchain/core/tools"; +import z from "zod"; +import mongoose from "mongoose"; +import { DBModel } from "../../../../models/index.js"; + +const healthProfileQueryTool = tool( + async ({ profileId, userId, name, mobile, page = 1, pageSize = 20 }) => { + try { + // 按档案 _id 精确查询单条 + if (profileId) { + if (!mongoose.Types.ObjectId.isValid(profileId)) { + return { success: false, error: `Invalid profile ID: ${profileId}` }; + } + const doc = await DBModel.HealthProfile.findById(profileId).lean(); + if (!doc) { + return { success: false, error: `未找到 ID 为 ${profileId} 的健康档案` }; + } + return { success: true, data: [doc], total: 1 }; + } + + const filter = {}; + + if (userId) { + if (!mongoose.Types.ObjectId.isValid(userId)) { + return { success: false, error: `Invalid user ID: ${userId}` }; + } + filter.userId = new mongoose.Types.ObjectId(userId); + } + if (name) { + // 转义正则特殊字符,避免查询报错或 ReDoS;姓名支持中文、全拼、拼音首字母匹配 + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = { $regex: escaped, $options: "i" }; + filter.$or = [{ "profile.name": re }, { "profile.pinyin": re }, { "profile.pinyinFL": re }]; + } + if (mobile) { + filter["profile.mobile"] = mobile; + } + + const skip = (page - 1) * pageSize; + + const [list, total] = await Promise.all([ + DBModel.HealthProfile.find(filter) + .sort({ "meta.createtime": -1 }) + .skip(skip) + .limit(pageSize) + .lean(), + DBModel.HealthProfile.countDocuments(filter), + ]); + + return { + success: true, + data: list, + total, + page, + pageSize, + }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + { + name: "health_profile_query", + description: + "查询患者健康档案。支持按档案 ID 精确查询,或按用户 ID、患者姓名(模糊)、患者电话筛选,返回分页结果。", + schema: z.object({ + profileId: z + .string() + .optional() + .describe("健康档案 ID(_id),提供时精确查询单条,忽略其他筛选条件"), + userId: z.string().optional().describe("所属用户ID(精确匹配)"), + name: z.string().optional().describe("患者姓名(支持中文模糊、全拼、拼音首字母匹配,如 张三/zhangsan/zs)"), + mobile: z.string().optional().describe("患者电话(精确匹配)"), + page: z.number().int().min(1).optional().describe("页码,从 1 开始"), + pageSize: z + .number() + .int() + .min(1) + .max(50) + .optional() + .describe("每页数量(1-50),默认 20"), + }), + } +); + +export { healthProfileQueryTool }; diff --git a/agent/infra/tools/db/health_profile_set.js b/agent/infra/tools/db/health_profile_set.js new file mode 100644 index 0000000..9c1de81 --- /dev/null +++ b/agent/infra/tools/db/health_profile_set.js @@ -0,0 +1,140 @@ +import { tool } from "@langchain/core/tools"; +import z from "zod"; +import mongoose from "mongoose"; +import { DBModel } from "../../../../models/index.js"; +import { genPinyin, genPinyinFL } from "../../../../models/schema/health_profile.js"; + +const PROFILE_KEYS = ["name", "mobile", "sex", "birth", "idnumber"]; +const LOCATION_KEYS = ["country", "province", "city", "district", "address"]; +const HEALTH_KEYS = ["height", "weight", "bloodType", "remark"]; + +const profileSchema = z + .object({ + name: z.string().optional().describe("患者姓名"), + mobile: z.string().optional().describe("患者电话"), + sex: z.enum(["male", "female"]).optional().describe("性别"), + birth: z.string().optional().describe("出生年月(YYYY-MM-DD)"), + idnumber: z.string().optional().describe("证件号(身份证/护照等)"), + }) + .optional() + .describe("患者基本信息"); + +const locationSchema = z + .object({ + country: z.string().optional().describe("国家(国外用户填国家,国内默认中国)"), + province: z.string().optional().describe("省"), + city: z.string().optional().describe("市"), + district: z.string().optional().describe("区/县"), + address: z.string().optional().describe("详细地址"), + }) + .optional() + .describe("所在地信息"); + +const healthSchema = z + .object({ + height: z.number().optional().describe("身高(cm)"), + weight: z.number().optional().describe("体重(kg)"), + bloodType: z.string().optional().describe("血型"), + remark: z.string().optional().describe("备注"), + }) + .optional() + .describe("健康信息"); + +const healthProfileSetTool = tool( + async ({ action, profileId, userId, profile, location, health }) => { + try { + if (action === "create") { + if (!profile || (!profile.name && !profile.mobile)) { + return { success: false, error: "创建健康档案至少需要提供患者姓名或电话" }; + } + + const docData = { + profile: { ...profile }, + location: { ...location }, + health: { ...health }, + meta: { createtime: new Date(), updatetime: new Date() }, + }; + if (userId) { + if (!mongoose.Types.ObjectId.isValid(userId)) { + return { success: false, error: `Invalid user ID: ${userId}` }; + } + docData.userId = new mongoose.Types.ObjectId(userId); + } + + const doc = await DBModel.HealthProfile.create(docData); + return { success: true, data: doc }; + } + + if (action === "update") { + if (!profileId) { + return { success: false, error: "更新健康档案必须提供 profileId(可先用 health_profile_query 查询定位)" }; + } + if (!mongoose.Types.ObjectId.isValid(profileId)) { + return { success: false, error: `Invalid profile ID: ${profileId}` }; + } + + const update = {}; + if (userId) { + if (!mongoose.Types.ObjectId.isValid(userId)) { + return { success: false, error: `Invalid user ID: ${userId}` }; + } + update.userId = new mongoose.Types.ObjectId(userId); + } + if (profile) { + for (const key of PROFILE_KEYS) { + if (profile[key] !== undefined) update[`profile.${key}`] = profile[key]; + } + // 更新姓名时同步拼音字段(findByIdAndUpdate 不触发 pre("save")) + if (profile.name !== undefined) { + update["profile.pinyin"] = genPinyin(profile.name); + update["profile.pinyinFL"] = genPinyinFL(profile.name); + } + } + if (location) { + for (const key of LOCATION_KEYS) { + if (location[key] !== undefined) update[`location.${key}`] = location[key]; + } + } + if (health) { + for (const key of HEALTH_KEYS) { + if (health[key] !== undefined) update[`health.${key}`] = health[key]; + } + } + + if (Object.keys(update).length === 0) { + return { success: false, error: "未提供任何要更新的字段" }; + } + update["meta.updatetime"] = new Date(); + + const updated = await DBModel.HealthProfile.findByIdAndUpdate( + profileId, + { $set: update }, + { new: true } + ); + if (!updated) { + return { success: false, error: `未找到 ID 为 ${profileId} 的健康档案` }; + } + return { success: true, data: updated }; + } + + return { success: false, error: "未知的 action,可选值:create / update" }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + { + name: "health_profile_set", + description: + "创建或更新患者健康档案。create 新建档案(至少提供患者姓名或电话);update 按档案 ID 局部更新,仅修改提供的字段。更新前建议先用 health_profile_query 查询现有内容。", + schema: z.object({ + action: z.enum(["create", "update"]).describe("操作类型:create 新建 / update 更新"), + profileId: z.string().optional().describe("健康档案 ID(_id),update 时必填"), + userId: z.string().optional().describe("所属用户ID(可选,用于关联小程序用户)"), + profile: profileSchema, + location: locationSchema, + health: healthSchema, + }), + } +); + +export { healthProfileSetTool }; diff --git a/agent/infra/tools/db/hospital_info_get.js b/agent/infra/tools/db/hospital_info_get.js new file mode 100644 index 0000000..834833b --- /dev/null +++ b/agent/infra/tools/db/hospital_info_get.js @@ -0,0 +1,32 @@ +import { tool } from "@langchain/core/tools"; +import z from "zod"; +import { DBModel } from "../../../../models/index.js"; + +const hospitalInfoGetTool = tool( + async ({ hospitalId }) => { + try { + if (!hospitalId) { + return { success: false, error: "hospitalId 不能为空" }; + } + + const org = await DBModel.Organization.findById(hospitalId).lean(); + if (!org) { + return { success: false, error: `未找到 id 为「${hospitalId}」的机构` }; + } + + return { success: true, data: org }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + { + name: "hospital_info_get", + description: + "根据医院 id 获取医院完整信息。id 可通过 hospital_info_list 获取。", + schema: z.object({ + hospitalId: z.string().describe("医院 id(MongoDB ObjectId,来自 hospital_info_list)"), + }), + } +); + +export { hospitalInfoGetTool }; diff --git a/agent/infra/tools/db/hospital_info_list.js b/agent/infra/tools/db/hospital_info_list.js new file mode 100644 index 0000000..257b01f --- /dev/null +++ b/agent/infra/tools/db/hospital_info_list.js @@ -0,0 +1,38 @@ +import { tool } from "@langchain/core/tools"; +import z from "zod"; +import { DBModel } from "../../../../models/index.js"; + +const hospitalInfoListTool = tool( + async ({ keyword }) => { + try { + const filter = {}; + if (keyword) { + const escaped = String(keyword).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = { $regex: escaped, $options: "i" }; + filter.$or = [{ name: re }, { pinyin: re }, { pinyinFL: re }]; + } + + const total = await DBModel.Organization.countDocuments(filter); + // 轻量列表:只取 id+名称,用于先定位医院,再按 id 查详情 + const orgs = await DBModel.Organization.find(filter) + .sort({ name: 1 }) + .limit(1000) + .select("name") + .lean(); + + return { success: true, total, data: orgs.map((o) => ({ id: o._id, name: o.name })) }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + { + name: "hospital_info_list", + description: + "获取医院(名称、id)对列表,支持按名称/拼音关键词过滤,不传关键词返回全部。", + schema: z.object({ + keyword: z.string().optional().describe("医院名称/拼音/拼音首字母关键词(如 协和、xiehe),不传返回全部"), + }), + } +); + +export { hospitalInfoListTool }; diff --git a/agent/infra/tools/db/hospital_info_query.js b/agent/infra/tools/db/hospital_info_query.js new file mode 100644 index 0000000..1d4b0a5 --- /dev/null +++ b/agent/infra/tools/db/hospital_info_query.js @@ -0,0 +1,63 @@ +import { tool } from "@langchain/core/tools"; +import z from "zod"; +import { DBModel } from "../../../../models/index.js"; + +const hospitalInfoQueryTool = tool( + async ({ hospitalName, department, doctorName }) => { + try { + if (!hospitalName) { + return { success: false, error: "hospitalName 不能为空" }; + } + + const filter = { name: { $regex: hospitalName, $options: "i" } }; + const total = await DBModel.Organization.countDocuments(filter); + if (total === 0) { + return { success: false, error: `未找到名称包含「${hospitalName}」的机构` }; + } + + // 医院总量小,一次最多取 10 家,命中过多时由调用方细化名称 + const orgs = await DBModel.Organization.find(filter).limit(10).lean(); + + const data = orgs.map((org) => { + const fa = org.forAgent ?? {}; + let departments = fa.departments ?? []; + let doctors = fa.doctors ?? []; + if (department) { + departments = departments.filter((d) => d.name?.includes(department)); + doctors = doctors.filter((d) => d.department?.includes(department)); + } + if (doctorName) { + doctors = doctors.filter((d) => d.name?.includes(doctorName)); + } + return { + _id: org._id, + name: org.name, + level: org.level ?? "", + address: org.address ?? {}, + infoUpdatedAt: fa.updatedAt ?? null, + overview: fa.overview ?? "", + photos: fa.photos ?? [], + departments, + doctors, + guides: fa.guides ?? {}, + }; + }); + + return { success: true, total, data }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + { + name: "hospital_info_query", + description: + "查询医院就医信息:医院总览(门诊/挂号/缴费/医保/交通)、科室(位置/电话/就诊提示)、医生(职称/擅长/号别/坐班)、场景指南(初诊/住院/检查须知/老人服务)、照片。按医院名称模糊查询,可按科室、医生名过滤。最多返回 10 家医院信息。", + schema: z.object({ + hospitalName: z.string().describe("医院名称,支持模糊匹配(如 协和)"), + department: z.string().optional().describe("科室名称过滤(如 风湿免疫科、放射科)"), + doctorName: z.string().optional().describe("医生姓名过滤"), + }), + } +); + +export { hospitalInfoQueryTool }; diff --git a/agent/infra/tools/db/hospital_info_set.js b/agent/infra/tools/db/hospital_info_set.js new file mode 100644 index 0000000..eceacab --- /dev/null +++ b/agent/infra/tools/db/hospital_info_set.js @@ -0,0 +1,143 @@ +import { tool } from "@langchain/core/tools"; +import z from "zod"; +import { DBModel } from "../../../../models/index.js"; + +const hospitalInfoSetTool = tool( + async ({ hospitalName, section, action, content, guides, entry, entryName, updatedBy }) => { + try { + if (!hospitalName) { + return { success: false, error: "hospitalName 不能为空" }; + } + + // 定位医院:名称必须唯一命中 + const orgs = await DBModel.Organization.find({ name: { $regex: hospitalName, $options: "i" } }).exec(); + if (orgs.length === 0) { + return { success: false, error: `未找到名称包含「${hospitalName}」的机构,请确认医院名称后重试` }; + } + if (orgs.length > 1) { + return { + success: false, + error: `名称包含「${hospitalName}」的机构有 ${orgs.length} 家(${orgs.map((o) => o.name).join("、")}),请使用更精确的名称`, + }; + } + const org = orgs[0]; + const fa = org.forAgent ?? {}; + + const update = {}; + + if (section === "overview") { + // 文本块全量替换 + if (typeof content !== "string") { + return { success: false, error: "overview 需要提供 content 文本(写入前请先查询现有内容并合并)" }; + } + update["forAgent.overview"] = content; + } else if (section === "guides") { + // 场景键值对浅合并,值为 null 表示删除该场景 + if (!guides || typeof guides !== "object" || Array.isArray(guides)) { + return { success: false, error: "guides 需要提供对象,键为场景名,值为文本(值为 null 表示删除该场景)" }; + } + const merged = { ...(fa.guides ?? {}) }; + for (const [key, value] of Object.entries(guides)) { + if (value === null) { + delete merged[key]; + } else { + merged[key] = value; + } + } + update["forAgent.guides"] = merged; + } else if (section === "photos" || section === "departments" || section === "doctors") { + // 列表区块:按锚点 upsert(新增或整条替换)/ remove + const list = [...(fa[section] ?? [])]; + + if (action === "upsert") { + if (!entry || typeof entry !== "object") { + return { success: false, error: "upsert 需要提供 entry 对象" }; + } + let idx = -1; + if (section === "photos") { + if (!entry.url) return { success: false, error: "photos 条目必须包含 url 作为锚点" }; + idx = list.findIndex((p) => p.url === entry.url); + } else if (section === "doctors") { + if (!entry.name || !entry.department) { + return { success: false, error: "doctors 条目必须包含 name 和 department 作为锚点" }; + } + idx = list.findIndex((d) => d.name === entry.name && d.department === entry.department); + } else { + if (!entry.name) return { success: false, error: "departments 条目必须包含 name 作为锚点" }; + idx = list.findIndex((d) => d.name === entry.name); + } + if (idx >= 0) { + list[idx] = { ...list[idx], ...entry }; + } else { + list.push(entry); + } + update[`forAgent.${section}`] = list; + } else if (action === "remove") { + if (!entryName) { + return { + success: false, + error: `remove 需要提供 entryName(${section === "photos" ? "照片url" : section === "doctors" ? "医生姓名" : "科室名称"})`, + }; + } + if (section === "photos") { + update[`forAgent.${section}`] = list.filter((p) => p.url !== entryName); + } else { + update[`forAgent.${section}`] = list.filter((d) => d.name !== entryName); + } + } else { + return { success: false, error: "列表区块(photos/departments/doctors)需要指定 action: upsert 或 remove" }; + } + } else { + return { success: false, error: "未知的 section,可选值:overview / photos / departments / doctors / guides" }; + } + + update["forAgent.updatedAt"] = new Date(); + if (updatedBy) { + update["forAgent.updatedBy"] = updatedBy; + } + + const updated = await DBModel.Organization.findByIdAndUpdate(org._id, { $set: update }, { new: true }).exec(); + return { success: true, data: updated.forAgent }; + } catch (error) { + return { success: false, error: error.message }; + } + }, + { + name: "hospital_info_set", + description: + "录入/更新医院就医信息。按区块维护:overview(医院级文本,全量替换,先查后改避免丢失)、guides(场景指南键值对,浅合并)、photos/departments/doctors(列表,按锚点 upsert 或 remove)。医院名称必须唯一命中。", + schema: z.object({ + hospitalName: z.string().describe("医院名称(需唯一命中,如 北京协和医院)"), + section: z + .enum(["overview", "photos", "departments", "doctors", "guides"]) + .describe("要更新的区块"), + action: z + .enum(["set", "upsert", "remove"]) + .optional() + .describe("操作类型:overview/guides 用 set;photos/departments/doctors 用 upsert 或 remove"), + content: z.string().optional().describe("overview 区块的完整文本(全量替换)"), + guides: z + .record(z.string(), z.string().nullable()) + .optional() + .describe('guides 区块的场景键值对,如 {"住院流程": "..."};值为 null 表示删除该场景'), + entry: z + .object({ + name: z.string().optional().describe("科室名或医生名(锚点)"), + department: z.string().optional().describe("医生所属科室(医生锚点之一)"), + detail: z + .string() + .optional() + .describe("信息全文:科室(位置/电话/类型/提示)或医生(职称/擅长/号别/坐班停诊)"), + photo: z.string().optional().describe("照片链接"), + url: z.string().optional().describe("照片链接(photos 锚点)"), + caption: z.string().optional().describe("照片说明"), + }) + .optional() + .describe("photos/departments/doctors 条目(upsert 时必填)"), + entryName: z.string().optional().describe("remove 的锚点:科室名称 / 医生姓名 / 照片url"), + updatedBy: z.string().optional().describe("操作人标识(用户ID或姓名),用于审计"), + }), + } +); + +export { hospitalInfoSetTool }; diff --git a/agent/infra/tools/index.js b/agent/infra/tools/index.js index 628f32f..934e391 100644 --- a/agent/infra/tools/index.js +++ b/agent/infra/tools/index.js @@ -20,4 +20,11 @@ export { getEnvTool } from './system/envs.js'; // db export { createEscortRecordQueryTool } from './db/escort_record_query.js'; +export { escortRecordQueryTool } from './db/escort_record_query_admin.js'; export { escortRecordSetTool } from './db/escort_record_set.js'; +export { healthProfileQueryTool } from './db/health_profile_query.js'; +export { healthProfileSetTool } from './db/health_profile_set.js'; +export { hospitalInfoListTool } from './db/hospital_info_list.js'; +export { hospitalInfoGetTool } from './db/hospital_info_get.js'; +export { hospitalInfoQueryTool } from './db/hospital_info_query.js'; +export { hospitalInfoSetTool } from './db/hospital_info_set.js'; diff --git a/agent/subagent/hospital_info.js b/agent/subagent/hospital_info.js new file mode 100644 index 0000000..3a8d29a --- /dev/null +++ b/agent/subagent/hospital_info.js @@ -0,0 +1,28 @@ +import { + hospitalInfoListTool, + hospitalInfoGetTool, + hospitalInfoQueryTool, + hospitalInfoSetTool, +} from "../infra/tools/index.js"; + +// 医院就医信息查询与维护(医生资料/坐班、科室、看病指南等) +export const createHospitalInfoSubagent = (model) => ({ + name: "hospital-info-subagent", + description: "查询和维护医院基本资料及就医信息:医院电话、医生资料、出诊信息、科室位置与电话、看病指南(门诊/挂号/医保/住院/检查须知)等", + systemPrompt: `你负责医院就医信息的查询、维护、更新、核验。 + +查询: +- 先用 hospital_info_list 拿到医院(名称、id)列表定位医院,再用 hospital_info_get 按医院 id 获取完整信息。 +- 需要按科室、医生名过滤时,可用名称模糊查询,返回精准信息。 +- 出诊信息等时效性数据必须注明信息更新时间,并提示用户以医院最新公告为准。 +- 查不到的信息如实告知,禁止编造。 + +维护: +- overview:医院级文本全量替换,写入前先查询现有内容,合并整理后整体写入,不得丢失原有信息。 +- guides:场景键值对浅合并,新增场景直接传键值对;删除场景将值置为 null。 +- photos/departments/doctors:列表按锚点操作,锚点为 照片=url、科室=name、医生=name+department;upsert 新增或整条更新,remove 删除。 +- doctors 的 detail 包含职称、擅长方向、号别(普通/专家/特需)、坐班与停诊安排。 +- 尽量提供 updatedBy 记录操作人。图片只存 URL 链接,不负责图片上传。`, + model, + tools: [hospitalInfoListTool, hospitalInfoGetTool, hospitalInfoQueryTool, hospitalInfoSetTool], +}); diff --git a/agent/subagent/patient_record.js b/agent/subagent/patient_record.js new file mode 100644 index 0000000..2765449 --- /dev/null +++ b/agent/subagent/patient_record.js @@ -0,0 +1,24 @@ +import { + escortRecordQueryTool, + escortRecordSetTool, + healthProfileQueryTool, + healthProfileSetTool, +} from "../infra/tools/index.js"; + +// 患者记录:陪诊预约记录 + 健康档案的查询和设置 +export const createPatientRecordSubagent = (model) => ({ + name: "patient-record-subagent", + description: "查询和设置患者记录:陪诊预约记录、健康档案", + systemPrompt: `根据用户指令,调用工具完成患者记录的查询和设置。 + +陪诊预约记录: +- 查询用 escort_record_query,更新用 escort_record_set(按记录 _id 定位)。 + +健康档案: +- 查询用 health_profile_query(可按姓名模糊、电话精确、档案 ID 定位),创建/更新用 health_profile_set。 +- 更新前先查询现有档案,仅修改用户明确要求变更的字段,不得覆盖其他信息。 +- 涉及敏感信息(证件号、电话、地址),仅按需记录和返回,禁止编造。`, + model, + mode: "fork", // deepagents 1.13:继承主对话历史,管理员指令中的上下文(患者、订单等)无需复述 + tools: [escortRecordQueryTool, escortRecordSetTool, healthProfileQueryTool, healthProfileSetTool], +}); diff --git a/agent/subagent/research.js b/agent/subagent/research.js new file mode 100644 index 0000000..873023a --- /dev/null +++ b/agent/subagent/research.js @@ -0,0 +1,10 @@ +import { webFetchTool, webSearchTool } from "../infra/tools/index.js"; + +// 陪诊(陪同就医)行业问题研究和解答 +export const createResearchSubagent = (model) => ({ + name: "escort-research-subagent", + description: "陪诊(陪同就医)行业问题研究和解答", + systemPrompt: "你是陪诊(陪同就医)行业政策、发展趋势、行业知识研究和解答专家。", + model, + tools: [webFetchTool, webSearchTool], +}); diff --git a/conf.json b/conf.json index b79ca95..77f0f60 100644 --- a/conf.json +++ b/conf.json @@ -7,6 +7,7 @@ }, "agent": { "maxHistoryChars": 40000, + "dataDir": "./data", "model": "deepseek", "deepseek": { "apiKey": "sk-a58ccd82b7ba4ce3ac176a88c9381095", diff --git a/config.js b/config.js new file mode 100644 index 0000000..e69de29 diff --git a/models/schema/employee.js b/models/schema/employee.js index 8d55130..f4fbc53 100644 --- a/models/schema/employee.js +++ b/models/schema/employee.js @@ -1,6 +1,7 @@ "use strict"; import mongoose from "mongoose"; +import { pinyin } from "pinyin-pro"; /** * Employee Schema @@ -8,8 +9,7 @@ import mongoose from "mongoose"; */ const EmployeeSchema = mongoose.Schema( { - // 基础信息 - name: { type: String, required: true, comment: "姓名" }, + // 工号(业务雇佣标识) employeeNo: { type: String, default: "", unique: true, sparse: true, comment: "工号" }, // 登录账号(关联用户体系) @@ -38,6 +38,9 @@ const EmployeeSchema = mongoose.Schema( // 个人信息 profile: { + name: { type: String, required: true, comment: "姓名" }, + pinyin: { type: String, default: "", index: true, comment: "姓名的拼音,用于搜索" }, + pinyinFL: { type: String, default: "", index: true, comment: "姓名拼音的首字母,用于搜索" }, sex: { type: String, enum: ["male", "female", "other", ""], default: "", comment: "性别" }, birthday: { type: Date, comment: "出生日期" }, idNumber: { type: String, default: "", comment: "身份证号" }, @@ -98,6 +101,31 @@ const EmployeeSchema = mongoose.Schema( } ); +// ==================== 拼音生成 ==================== + +/** + * 姓名 → 全拼(小写、无分隔,如 "张三" → "zhangsan") + */ +const genPinyin = (name) => + pinyin(String(name), { toneType: "none", nonZh: "consecutive" }).toLowerCase().replace(/[^a-z0-9]/g, ""); + +/** + * 姓名 → 拼音首字母(如 "张三" → "zs") + */ +const genPinyinFL = (name) => + pinyin(String(name), { pattern: "first", toneType: "none", nonZh: "consecutive" }).toLowerCase().replace(/[^a-z0-9]/g, ""); + +// 保存时自动生成/同步拼音字段 +EmployeeSchema.pre("save", function (next) { + const name = this.profile?.name; + if (!name) return next(); + if (this.isModified("profile.name") || !this.profile.pinyin || !this.profile.pinyinFL) { + this.profile.pinyin = genPinyin(name); + this.profile.pinyinFL = genPinyinFL(name); + } + next(); +}); + // ==================== 静态方法 ==================== /** @@ -156,6 +184,13 @@ EmployeeSchema.statics.createEmployee = async function (data) { * 更新员工 */ EmployeeSchema.statics.updateEmployee = async function (id, update) { + const newName = update.name || update["profile.name"]; + if (newName) { + update["profile.name"] = newName; + delete update.name; + update["profile.pinyin"] = genPinyin(newName); + update["profile.pinyinFL"] = genPinyinFL(newName); + } update["meta.updatetime"] = Date.now(); return await this.findByIdAndUpdate(id, { $set: update }, { new: true }).exec(); }; @@ -179,7 +214,7 @@ EmployeeSchema.statics.findOnlineAttendants = async function (options = {}) { // ==================== 索引定义 ==================== -EmployeeSchema.index({ name: 1 }); +EmployeeSchema.index({ "profile.name": 1 }); EmployeeSchema.index({ employeeNo: 1 }); EmployeeSchema.index({ userId: 1 }); EmployeeSchema.index({ orgId: 1, role: 1, status: 1 }); diff --git a/models/schema/health_profile.js b/models/schema/health_profile.js index 1f9ca92..0dbc3cc 100644 --- a/models/schema/health_profile.js +++ b/models/schema/health_profile.js @@ -1,6 +1,7 @@ "use strict"; import mongoose from "mongoose"; +import { pinyin } from "pinyin-pro"; /** * HealthProfile Schema @@ -19,6 +20,8 @@ const HealthProfileSchema = mongoose.Schema( // 患者信息 profile: { name: { type: String, default: "", comment: "患者姓名" }, + pinyin: { type: String, default: "", index: true, comment: "患者姓名全拼(小写无分隔,用于搜索)" }, + pinyinFL: { type: String, default: "", index: true, comment: "患者姓名拼音首字母(用于搜索)" }, mobile: { type: String, default: "", index: true, comment: "患者电话" }, sex: { type: String, enum: ["male", "female", ""], default: "", comment: "性别" }, birth: { type: String, default: "", comment: "出生年月(YYYY-MM-DD)" }, @@ -56,8 +59,44 @@ const HealthProfileSchema = mongoose.Schema( } ); +// ==================== 拼音生成 ==================== + +/** + * 患者姓名 → 全拼(小写、无分隔,如 "张三" → "zhangsan") + */ +const genPinyin = (name) => + pinyin(String(name), { toneType: "none", nonZh: "consecutive" }).toLowerCase().replace(/[^a-z0-9]/g, ""); + +/** + * 患者姓名 → 拼音首字母(如 "张三" → "zs") + */ +const genPinyinFL = (name) => + pinyin(String(name), { pattern: "first", toneType: "none", nonZh: "consecutive" }).toLowerCase().replace(/[^a-z0-9]/g, ""); + +// 保存时自动生成/同步姓名拼音字段 +HealthProfileSchema.pre("save", function (next) { + const name = this.profile?.name; + if (!name) return next(); + if (this.isModified("profile.name") || !this.profile.pinyin || !this.profile.pinyinFL) { + this.profile.pinyin = genPinyin(name); + this.profile.pinyinFL = genPinyinFL(name); + } + next(); +}); + // ==================== 静态方法 ==================== +/** + * 更新包含姓名时同步拼音字段(update 为带 dotted path 的扁平对象) + */ +const syncPinyinOnUpdate = (update) => { + const name = update["profile.name"] ?? update.profile?.name; + if (name) { + update["profile.pinyin"] = genPinyin(name); + update["profile.pinyinFL"] = genPinyinFL(name); + } +}; + /** * 根据用户ID查找健康档案 */ @@ -85,9 +124,10 @@ HealthProfileSchema.statics.findProfiles = async function (options = {}) { filter.userId = userId; } if (name) { - // 转义正则特殊字符,避免查询报错或 ReDoS + // 转义正则特殊字符,避免查询报错或 ReDoS;姓名支持中文、全拼、拼音首字母匹配 const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - filter["profile.name"] = { $regex: escaped, $options: "i" }; + const re = { $regex: escaped, $options: "i" }; + filter.$or = [{ "profile.name": re }, { "profile.pinyin": re }, { "profile.pinyinFL": re }]; } if (mobile) { filter["profile.mobile"] = mobile; @@ -124,17 +164,19 @@ HealthProfileSchema.statics.createProfile = async function (data) { }; /** - * 根据ID更新健康档案 + * 根据ID更新健康档案(更新姓名时同步拼音字段) */ HealthProfileSchema.statics.updateProfileById = async function (id, update) { + syncPinyinOnUpdate(update); update["meta.updatetime"] = Date.now(); return await this.findByIdAndUpdate(id, { $set: update }, { new: true }).exec(); }; /** - * 根据用户ID更新健康档案 + * 根据用户ID更新健康档案(更新姓名时同步拼音字段) */ HealthProfileSchema.statics.updateProfile = async function (userId, update) { + syncPinyinOnUpdate(update); update["meta.updatetime"] = Date.now(); return await this.findOneAndUpdate({ userId }, { $set: update }, { new: true }).exec(); }; @@ -151,4 +193,4 @@ HealthProfileSchema.statics.deleteProfileById = async function (id) { HealthProfileSchema.index({ userId: 1 }, { sparse: true }); HealthProfileSchema.index({ "profile.mobile": 1 }); -export { HealthProfileSchema }; \ No newline at end of file +export { HealthProfileSchema, genPinyin, genPinyinFL }; \ No newline at end of file diff --git a/models/schema/organization.js b/models/schema/organization.js index bfe29ad..e300324 100644 --- a/models/schema/organization.js +++ b/models/schema/organization.js @@ -1,6 +1,7 @@ "use strict"; import mongoose from "mongoose"; +import { pinyin } from "pinyin-pro"; /** * Organization Schema @@ -11,7 +12,9 @@ const OrganizationSchema = mongoose.Schema( // 基础信息 name: { type: String, required: true, index: true, comment: "机构名称" }, shortName: { type: String, default: "", comment: "机构简称" }, - code: { type: String, default: "", unique: true, sparse: true, comment: "机构编码" }, + pinyin: { type: String, default: "", index: true, comment: "机构名称全拼(小写无分隔,用于搜索)" }, + pinyinFL: { type: String, default: "", index: true, comment: "机构名称拼音首字母(用于搜索)" }, + code: { type: String, unique: true, sparse: true, comment: "机构编码(不填时字段缺省,避免与 unique sparse 索引冲突)" }, // 机构类型 type: { @@ -64,6 +67,39 @@ const OrganizationSchema = mongoose.Schema( description: { type: String, default: "", comment: "机构简介" }, remark: { type: String, default: "", comment: "备注" }, + // 就医信息(agent-first:少字段、大文本;由 AI 助手录入维护,供查询与看病指导) + forAgent: { + updatedAt: { type: Date, comment: "最近更新时间" }, + updatedBy: { type: String, default: "", comment: "最近更新人" }, + + // 医院级总览:门诊时间、挂号渠道、缴费、医保、异地备案、交通停车,一个文本块全包含 + overview: { type: String, default: "", comment: "医院级信息全文(门诊/挂号/缴费/医保/交通)" }, + + // 医院/导引照片 + photos: [{ + url: { type: String, default: "", comment: "图片链接" }, + caption: { type: String, default: "", comment: "说明(医院照片/导引照片 + 描述)" }, + }], + + // 科室:name 是锚点;detail 包含位置/电话/临床还是医技/就诊提示全文 + departments: [{ + name: { type: String, comment: "科室名称(锚点)" }, + detail: { type: String, default: "", comment: "科室信息全文(位置/电话/类型/就诊提示)" }, + photo: { type: String, default: "", comment: "科室位置导引照片链接" }, + }], + + // 医生:name+department 是锚点;detail 包含职称/擅长/号别/坐班停诊全文 + doctors: [{ + name: { type: String, comment: "医生姓名(锚点)" }, + department: { type: String, comment: "所属科室(锚点)" }, + detail: { type: String, default: "", comment: "医生信息全文(职称/擅长/号别/坐班停诊)" }, + photo: { type: String, default: "", comment: "医生照片链接" }, + }], + + // 场景指南:初诊/复诊/住院/检查须知/老人服务等,键为场景名,值为文本,结构自由演化 + guides: { type: mongoose.Schema.Types.Mixed, comment: "场景指南(键为场景名,值为文本)" }, + }, + // 元数据 meta: { createtime: { type: Date, default: Date.now, comment: "创建时间" }, @@ -78,15 +114,41 @@ const OrganizationSchema = mongoose.Schema( } ); +// ==================== 拼音生成 ==================== + +/** + * 机构名称 → 全拼(小写、无分隔,如 "北京协和医院" → "beijingxieheyiyuan") + */ +const genPinyin = (name) => + pinyin(String(name), { toneType: "none", nonZh: "consecutive" }).toLowerCase().replace(/[^a-z0-9]/g, ""); + +/** + * 机构名称 → 拼音首字母(如 "北京协和医院" → "bjxyy") + */ +const genPinyinFL = (name) => + pinyin(String(name), { pattern: "first", toneType: "none", nonZh: "consecutive" }).toLowerCase().replace(/[^a-z0-9]/g, ""); + +// 保存时自动生成/同步拼音字段 +OrganizationSchema.pre("save", function (next) { + if (!this.name) return next(); + if (this.isModified("name") || !this.pinyin || !this.pinyinFL) { + this.pinyin = genPinyin(this.name); + this.pinyinFL = genPinyinFL(this.name); + } + next(); +}); + // ==================== 静态方法 ==================== /** - * 根据名称模糊查询机构 + * 根据名称/拼音/拼音首字母模糊查询机构 */ OrganizationSchema.statics.findByName = async function (name, options = {}) { const { page = 1, pageSize = 20 } = options; const skip = (page - 1) * pageSize; - return await this.find({ name: { $regex: name, $options: "i" } }) + const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = { $regex: escaped, $options: "i" }; + return await this.find({ $or: [{ name: re }, { pinyin: re }, { pinyinFL: re }] }) .skip(skip) .limit(pageSize) .exec(); @@ -124,6 +186,10 @@ OrganizationSchema.statics.createOrg = async function (data) { * 更新机构 */ OrganizationSchema.statics.updateOrg = async function (id, update) { + if (update.name) { + update.pinyin = genPinyin(update.name); + update.pinyinFL = genPinyinFL(update.name); + } update["meta.updatetime"] = Date.now(); return await this.findByIdAndUpdate(id, { $set: update }, { new: true }).exec(); }; diff --git a/package-lock.json b/package-lock.json index 563e13c..7901a08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "moment": "^2.30.1", "mongoose": "^8.24.0", "node-fetch": "^3.3.2", + "pinyin-pro": "^3.29.3", "winston": "^3.19.0", "ws": "^8.21.0", "zod": "^4.5.4" @@ -912,9 +913,9 @@ "license": "MIT" }, "node_modules/deepagents": { - "version": "1.13.2", - "resolved": "https://registry.npmmirror.com/deepagents/-/deepagents-1.13.2.tgz", - "integrity": "sha512-OMm+Ark4yaICZhGqC9kYkIx5vw5eH+GqIhzX1PAMmYhdxK5XeBTyn4pdfo1fKrBj2X/8GEg6TPnKS2jORLqJAQ==", + "version": "1.13.3", + "resolved": "https://registry.npmmirror.com/deepagents/-/deepagents-1.13.3.tgz", + "integrity": "sha512-ApjoznYieCpMRFH7TlxNYU9n1QVQp+IGJyzVnxRV0UFqfalDATI64ddWnrBoaHaLU1qb3bMrqgvKPInjlsPjgA==", "license": "MIT", "dependencies": { "fast-glob": "^3.3.3", @@ -2449,6 +2450,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pinyin-pro": { + "version": "3.29.3", + "resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.29.3.tgz", + "integrity": "sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmmirror.com/pstree.remy/-/pstree.remy-1.1.8.tgz", diff --git a/package.json b/package.json index 030a207..2754fd0 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "moment": "^2.30.1", "mongoose": "^8.24.0", "node-fetch": "^3.3.2", + "pinyin-pro": "^3.29.3", "winston": "^3.19.0", "ws": "^8.21.0", "zod": "^4.5.4" diff --git a/websocket.js b/websocket.js index 5f63e0d..731397d 100644 --- a/websocket.js +++ b/websocket.js @@ -1,8 +1,8 @@ import WebSocket, { WebSocketServer } from 'ws'; import http from 'http'; import { DBModel } from "./models/index.js"; -import { chatTask } from "./agent/escort/task.js"; -import { adminAgent } from "./agent/escort-admin/agent.js" +import { chatTask } from "./agent/bots/bot-escort/task.js"; +import { adminAgent } from "./agent/bots/bot-escort-admin/agent.js" import { getUserInfo } from "./utils/user_service.js"; export default class WebSocketServerManager {