tmp
This commit is contained in:
@@ -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)]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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里的信息不轻易输出给用户。
|
||||
- 每次保存时,先把新信息与已有记忆整理成完整的记忆内容,再用 `write_file` 一次性整体重写 `/memories/memory.txt`(write_file 支持整文件覆盖,无需先读后改或多次编辑)
|
||||
- 为保护用户隐私,除非用户询问自己的健康或医疗相关资料,否则/memories/memory.txt里的信息不轻易输出给用户。
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
@@ -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],
|
||||
});
|
||||
@@ -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],
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
},
|
||||
"agent": {
|
||||
"maxHistoryChars": 40000,
|
||||
"dataDir": "./data",
|
||||
"model": "deepseek",
|
||||
"deepseek": {
|
||||
"apiKey": "sk-a58ccd82b7ba4ce3ac176a88c9381095",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
export { HealthProfileSchema, genPinyin, genPinyinFL };
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Generated
+10
-3
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user