diff --git a/agent/escort-admin/agent.js b/agent/escort-admin/agent.js index 62af252..27f0cf7 100644 --- a/agent/escort-admin/agent.js +++ b/agent/escort-admin/agent.js @@ -3,124 +3,149 @@ import { createDeepAgent, FilesystemBackend } from "deepagents"; import { AIMessageChunk, ToolMessage } from "langchain"; import { SystemMessage, HumanMessage, AIMessage } from "@langchain/core/messages"; import { ChatDeepSeek } from "@langchain/deepseek"; -import Prompts from "./prompts.js"; -import { getEnvTool,webFetchTool, webSearchTool, getCalendarInfoTool, - getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool, getLatLngTool, - httpGetTool, httpPostTool - } from "./tools/index.js"; +import EscortAdminPrompts from "./prompts.js"; +import { + getEnvTool, webFetchTool, webSearchTool, getCalendarInfoTool, + getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool, getLatLngTool, + httpGetTool, httpPostTool, escortRecordQueryTool +} from "./tools/index.js"; export default class EscortAdminAgent { - constructor() { - } - - clearMessages() { - this.messages = []; - } - - // msg: { agent: 'escort-admin', type: 'chat', ts: "2023-08-01 10:00:00", content: "你好" } - async streamChat(userInfo, msgs, callback) { - if (!msgs.length) { - return; + constructor() { } - const agent = this._genAgent(userInfo); - msgs.forEach(msg => { - if (msg.type === "clear") { + clearMessages() { this.messages = []; - } else { - this.messages.push(new HumanMessage(`${msg.ts} - ${msg.content}`)); - } - }); - - if (this.messages.length === 0) { - return; } - const INTERESTING_NODES = new Set(["model_request", "tools"]); - for await (const [namespace, mode, data] of await agent.stream( - { messages: this.messages }, - { - recursion_limit: 50, - streamMode: ["updates", "messages", "custom"], subgraphs: true, - configurable: { - thread_id: 'default-session' + // msg: { agent: 'escort-admin', type: 'chat', ts: "2023-08-01 10:00:00", content: "你好" } + async streamChat(userInfo, msgs, callback) { + if (!msgs.length) { + return; } - })) { - const isSubagent = namespace.some(s => s.startsWith("tools:")); - const source = isSubagent ? "subagent" : "main"; - if (mode === "updates") { - for (const nodeName of Object.keys(data)) { - if (!INTERESTING_NODES.has(nodeName)) continue; - // Main agent updates (empty namespace) - if (namespace.length === 0) { - for (const [nodeName, data_] of Object.entries(data)) { - if (nodeName === "tools") { - // Subagent results returned to main agent - for (const msg of data_.messages ?? []) { - if (msg.type === "tool") { - console.log(`\nSubagent complete: ${msg.name}`); - console.log(` Result: ${String(msg.content).slice(0, 200)}...`); - } + + const agent = this._genAgent(userInfo); + msgs.forEach(msg => { + if (msg.type === "clear") { + this.messages = []; + } else { + this.messages.push(new HumanMessage(`${msg.ts} - ${msg.content}`)); + } + }); + + if (this.messages.length === 0) { + return; + } + + const INTERESTING_NODES = new Set(["model_request", "tools"]); + for await (const [namespace, mode, data] of await agent.stream( + { messages: this.messages }, + { + recursion_limit: 50, + streamMode: ["updates", "messages", "custom"], subgraphs: true, + configurable: { + thread_id: 'default-session' } - } else if (nodeName === "model_request") { - this.messages.push(...data_.messages); - } + })) { + const isSubagent = namespace.some(s => s.startsWith("tools:")); + const source = isSubagent ? "subagent" : "main"; + if (mode === "updates") { + for (const nodeName of Object.keys(data)) { + if (!INTERESTING_NODES.has(nodeName)) continue; + // Main agent updates (empty namespace) + if (namespace.length === 0) { + for (const [nodeName, data_] of Object.entries(data)) { + if (nodeName === "tools") { + // Subagent results returned to main agent + for (const msg of data_.messages ?? []) { + if (msg.type === "tool") { + console.log(`\nSubagent complete: ${msg.name}`); + console.log(` Result: ${String(msg.content).slice(0, 200)}...`); + } + } + } else if (nodeName === "model_request") { + this.messages.push(...data_.messages); + } + } + } else { + // Subagent updates (non-empty namespace) + for (const [nodeName, data_] of Object.entries(data)) { + console.log(` [${namespace[0]}] step: ${nodeName}`); + } + } + } + } else if (mode === "messages") { + const [message] = data; + if (message.tool_call_chunks?.length) { + continue; + } + if (AIMessageChunk.isInstance(message)) { + if (message.text && !message.tool_call_chunks?.length) { + callback(source, "ai", message.text, message.id); + } + if (message.additional_kwargs.reasoning_content && !message.tool_call_chunks?.length) { + callback(source, "reasoning", message.additional_kwargs.reasoning_content, message.id); + } + } + if (ToolMessage.isInstance(message) && message.text) { + callback(source, "tool", message.text, message.id); + } + } else if (mode === "custom") { + this.logger.info("custom: ", data); } - } else { - // Subagent updates (non-empty namespace) - for (const [nodeName, data_] of Object.entries(data)) { - console.log(` [${namespace[0]}] step: ${nodeName}`); - } - } } - } else if (mode === "messages") { - const [message] = data; - if (message.tool_call_chunks?.length) { - continue; - } - if (AIMessageChunk.isInstance(message)) { - if (message.text && !message.tool_call_chunks?.length) { - callback(source, "ai", message.text, message.id); - } - if (message.additional_kwargs.reasoning_content && !message.tool_call_chunks?.length) { - callback(source, "reasoning", message.additional_kwargs.reasoning_content, message.id); - } - } - if (ToolMessage.isInstance(message) && message.text) { - callback(source, "tool", message.text, message.id); - } - } else if (mode === "custom") { - this.logger.info("custom: ", data); - } - } - } - - _genAgent(userInfo) { - if (this.agent) { - return this.agent; } - this.messages = []; + _genAgent(userInfo) { + if (this.agent) { + return this.agent; + } - const rootDir = process.cwd(); - const backend = new FilesystemBackend({ rootDir }); + this.messages = []; - this.model = new ChatDeepSeek({ - model: 'deepseek-v4-pro', - apiKey: 'sk-a58ccd82b7ba4ce3ac176a88c9381095', - temperature: 0.3 - }); + const rootDir = process.cwd(); + const backend = new FilesystemBackend({ rootDir }); - this.agent = createDeepAgent({ - name: "deep-agent", - model: this.model, - systemPrompt: Prompts.buildSystemPrompt(userInfo), - backend, - tools: [getEnvTool, webFetchTool, webSearchTool, getLatLngTool, httpGetTool, httpPostTool, - getCalendarInfoTool, getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool], - skills: ["./agent/escort/skills/"] - }); + this.flashModel = new ChatDeepSeek({ + model: 'deepseek-v4-flash', + apiKey: 'sk-a58ccd82b7ba4ce3ac176a88c9381095', + temperature: 0.0 + }); + this.proModel = new ChatDeepSeek({ + model: 'deepseek-v4-pro', + apiKey: 'sk-a58ccd82b7ba4ce3ac176a88c9381095', + temperature: 0.3 + }); - return this.agent; - } -} \ No newline at end of file + const escortRecordOperSubagent = { + name: "escort-record-oper-subagent", + description: "查询和设置陪诊预约记录", + systemPrompt: "根据用户指令,调用工具完成查询和设置陪诊记录。", + model: this.flashModel, + tools: [escortRecordQueryTool], + }; + + const escortResearchSubagent = { + name: "escort-research-subagent", + description: "陪诊(陪同就医)行业问题研究和解答", + systemPrompt: "你是陪诊(陪同就医)行业政策、发展趋势、行业知识研究和解答专家。", + model: this.proModel, + tools: [webFetchTool, webSearchTool], + }; + + this.agent = createDeepAgent({ + name: "deep-agent", + model: this.flashModel, + systemPrompt: EscortAdminPrompts.buildSystemPrompt(userInfo), + backend, + tools: [getEnvTool, webFetchTool, webSearchTool, getLatLngTool, httpGetTool, httpPostTool, + getCalendarInfoTool, getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool, + escortRecordQueryTool] + }); + + return this.agent; + } +} + +const adminAgent = new EscortAdminAgent(); +export { adminAgent }; \ No newline at end of file diff --git a/agent/escort-admin/prompts.js b/agent/escort-admin/prompts.js new file mode 100644 index 0000000..8c664bc --- /dev/null +++ b/agent/escort-admin/prompts.js @@ -0,0 +1,34 @@ +import moment from "moment"; +import services from "../../resource/services.js"; +import agreement from "../../resource/agreement.js"; + +class EscortAdminPrompts { + static buildSystemPrompt(userInfo) { + let userInfo_str = "用户未登录,提示用户先登录,并在'我的'中完善个人信息"; + if (userInfo) { + userInfo_str = JSON.stringify({ + name: userInfo.profile.name, + mobile: userInfo.profile.mobile, + role: userInfo.app + }); + } + + return ` +# 角色定义 +你是小暖,暖橙陪诊平台的管理助手,直接、高效的解决平台问题。 + +# 核心能力 +- 解答服务流程、价格、注意事项 +- 提供就诊准备建议 +- 通过tools和skills,为用户提供其他服务。如:查询天气、路线、查询医院信息、查询医生信息等。 + +# 铁律(必须遵守) +1. 思考和理解任务意图,专业、谨慎、高效、专注的完成任务。 + +## 参考信息 +管理员信息:${userInfo_str}; +`; + } +} + +export default EscortAdminPrompts; diff --git a/agent/escort-admin/tools/db/escort_record_query.js b/agent/escort-admin/tools/db/escort_record_query.js new file mode 100644 index 0000000..fe1df31 --- /dev/null +++ b/agent/escort-admin/tools/db/escort_record_query.js @@ -0,0 +1,66 @@ +import { tool } from "@langchain/core/tools"; +import z from "zod"; +import { DBModel } from "../../../../models/index.js"; + +const escortRecordQueryTool = tool( + async ({ patientName, mobile, status, page = 1, pageSize = 20 }) => { + try { + const filter = {}; + + if (patientName) { + filter["patient.name"] = { $regex: patientName, $options: "i" }; + } + + if (mobile) { + filter["patient.mobile"] = mobile; + } + + if (status) { + filter.status = status; + } + + const skip = (page - 1) * pageSize; + + const records = await DBModel.EscortRecord.find(filter) + .sort({ "schedule.date": -1 }) + .skip(skip) + .limit(pageSize) + .lean(); + + const total = await DBModel.EscortRecord.countDocuments(filter); + + return { + success: true, + data: records, + }; + } catch (error) { + return { + success: false, + error: error.message, + }; + } + }, + { + name: "escort_record_query", + description: + "Query escort records from database. Supports filtering by patient name, mobile phone number, and appointment status. Returns paginated results sorted by appointment date in descending order.", + schema: z.object({ + patientName: z + .string() + .optional() + .describe("Patient name for fuzzy search"), + mobile: z + .string() + .optional() + .describe("Patient mobile phone number for exact match"), + status: z + .enum(["pending", "confirmed", "in_progress", "completed", "cancelled"]) + .optional() + .describe( + "Appointment status: pending (待确认), confirmed (已确认), in_progress (进行中), completed (已完成), cancelled (已取消)" + ), + }), + } +); + +export { escortRecordQueryTool }; diff --git a/agent/escort-admin/tools/db/escort_record_set.js b/agent/escort-admin/tools/db/escort_record_set.js new file mode 100644 index 0000000..cd85fb7 --- /dev/null +++ b/agent/escort-admin/tools/db/escort_record_set.js @@ -0,0 +1,109 @@ +import { tool } from "@langchain/core/tools"; +import z from "zod"; +import { DBModel } from "../../../../models/index.js"; + +const escortRecordSetTool = tool( + async ({ mobile, status, notes, payment }) => { + try { + if (!mobile) { + return { + success: false, + error: "Mobile phone number is required as the lookup key", + }; + } + + const record = await DBModel.EscortRecord.findOne({ "patient.mobile": mobile }); + if (!record) { + return { + success: false, + error: `No escort record found for mobile: ${mobile}`, + }; + } + + const update = {}; + + if (status !== undefined) { + update.status = status; + } + + if (notes !== undefined) { + if (notes.patientNote !== undefined) { + update["notes.patientNote"] = notes.patientNote; + } + if (notes.escortNote !== undefined) { + update["notes.escortNote"] = notes.escortNote; + } + if (notes.medicalSummary !== undefined) { + update["notes.medicalSummary"] = notes.medicalSummary; + } + } + + if (payment !== undefined) { + if (payment.totalFee !== undefined) { + update["payment.totalFee"] = payment.totalFee; + } + if (payment.paidFee !== undefined) { + update["payment.paidFee"] = payment.paidFee; + } + if (payment.status !== undefined) { + update["payment.status"] = payment.status; + } + } + + update["meta.updatetime"] = Date.now(); + + const updatedRecord = await DBModel.EscortRecord.findByIdAndUpdate( + record._id, + { $set: update }, + { new: true } + ); + + return { + success: true, + data: updatedRecord, + }; + } catch (error) { + return { + success: false, + error: error.message, + }; + } + }, + { + name: "escort_record_set", + description: + "Update escort record fields by patient mobile phone number. Supports updating status, notes (patientNote, escortNote, medicalSummary), and payment (totalFee, paidFee, status). Only provided fields will be updated.", + schema: z.object({ + mobile: z + .string() + .describe("Patient mobile phone number used as the lookup key"), + status: z + .enum(["pending", "confirmed", "in_progress", "completed", "cancelled"]) + .optional() + .describe( + "Appointment status: pending (待确认), confirmed (已确认), in_progress (进行中), completed (已完成), cancelled (已取消)" + ), + notes: z + .object({ + patientNote: z.string().optional().describe("Patient remarks / special requirements"), + escortNote: z.string().optional().describe("Escort service notes"), + medicalSummary: z.string().optional().describe("Medical visit summary"), + }) + .optional() + .describe("Notes information to update"), + payment: z + .object({ + totalFee: z.number().optional().describe("Total service fee (RMB)"), + paidFee: z.number().optional().describe("Amount already paid (RMB)"), + status: z + .enum(["unpaid", "partial", "paid", "refunded"]) + .optional() + .describe("Payment status: unpaid, partial, paid, refunded"), + }) + .optional() + .describe("Payment information to update"), + }), + } +); + +export { escortRecordSetTool }; diff --git a/agent/escort-admin/tools/index.js b/agent/escort-admin/tools/index.js index c862dde..318e05c 100644 --- a/agent/escort-admin/tools/index.js +++ b/agent/escort-admin/tools/index.js @@ -16,5 +16,10 @@ export { getYearTermsTool } from './calendar/year_terms.js'; // 地理工具 (LangChain format) export { getLatLngTool } from './geo/latlon.js'; +// 系统工具 (LangChain format) export { getEnvTool } from './system/envs.js'; +// db +export { escortRecordQueryTool } from './db/escort_record_query.js'; + + diff --git a/models/schema/escort_record.js b/models/schema/escort_record.js index e16c71e..1e4827d 100644 --- a/models/schema/escort_record.js +++ b/models/schema/escort_record.js @@ -36,7 +36,7 @@ const EscortRecordSchema = mongoose.Schema( */ patient: { name: { type: String, default: "", comment: "患者姓名" }, - mobile: { type: String, default: "", comment: "患者联系电话" }, + mobile: { type: String, default: "", unique: true, index: true, comment: "患者联系电话" }, sex: { type: String, enum: ["male", "female"], comment: "患者性别" }, age: { type: Number, default: 0, comment: "患者年龄" }, idnumber: { type: String, default: "", comment: "患者身份证号" }, diff --git a/websocket.js b/websocket.js index 00bb8d2..ef6d2c6 100644 --- a/websocket.js +++ b/websocket.js @@ -2,6 +2,7 @@ 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" export default class WebSocketServerManager { constructor(port = 8080) { @@ -80,6 +81,10 @@ export default class WebSocketServerManager { if (msg.type === 'chat' || msg.type === 'clear') { if (msg.agent === 'escort-admin') { + const userInfo = await this.getUserInfo(msg.userId); + adminAgent.streamChat(userInfo, [msg], (source, type, content, id) => { + ws.send(JSON.stringify({ source, type, content, id })); + }); } else { const userInfo = await this.getUserInfo(msg.userId); chatTask.streamChat(userInfo, msg, (source, type, content, id) => {