From f439db88322566f4f0333e7c0f32dadb14896eed Mon Sep 17 00:00:00 2001 From: zhaoyingbo Date: Sun, 1 Dec 2024 08:30:39 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=94=A4=E9=86=92=E6=9C=BA=E5=99=A8=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lockb | Bin 159661 -> 159661 bytes cache.ts | 32 +++ constant/card.ts | 135 --------- controller/groupAgent/agent.ts | 64 +++++ controller/groupAgent/chatHistory.ts | 34 +-- controller/groupAgent/index.ts | 4 +- controller/groupAgent/manual.ts | 412 --------------------------- package.json | 4 +- routes/bot/actionMsg.ts | 7 +- routes/bot/eventMsg.ts | 21 +- test/groupAgent/agent.http | 4 + test/parseGroupAgentQuery.test.ts | 54 ++++ test/parseGroupAgentQuery.ts | 24 +- utils/llm/index.ts | 109 +++---- utils/time.ts | 76 ++++- 15 files changed, 301 insertions(+), 679 deletions(-) create mode 100644 cache.ts create mode 100644 controller/groupAgent/agent.ts delete mode 100644 controller/groupAgent/manual.ts create mode 100644 test/groupAgent/agent.http create mode 100644 test/parseGroupAgentQuery.test.ts diff --git a/bun.lockb b/bun.lockb index 54ef42561e15cef3d50aa68a867583fddacf762c..e6df58bfec71c910cfae9fecfc6c1fe1b638f453 100755 GIT binary patch delta 497 zcmVY`DA+6f4h8A`t0$&Gg1uU5+|S3u|-RKOsVY<##UZSUD8dO zQtOwH%N8U=v&EkH5kQ!+a%;kggeR&7RWjxya~_H-IX3j=kIrk^UvQ4k1b>kRYfA^e zkjadbX#T{RRAeh{ZbljOQ}9&MgM3h|U15{`uoysBz5rS9cWAu!XGG$-h!-onhUMcJ z6KyJfo^xX&fJ=byK_DVZpuI{6H^y#`8^T=HlD}WOIa%WGvjwN@{2w^8Rk4r+KzQJ^ zAekBAF>*QeBgwE{k`p~cIwpn_Nhzz%iNj@zUJ&J)Qs-BBhJ&iMqMF%+whBeezaAb> z!Us-|R_i52HnSzRS@{7smw>GXpp-FqgHj0h<9aw^6VG0|6T`0JQ`F01W^DfH#DG zr#ppcDgpssGcGeRE-|-nu>tQ00Wp{1x&aoqb-DqoJ^?YeA^8Ev50~>>0w9+N2?80H n^IHNSw?PR4ngbI#E-)`{WppicZ*OcZF)lGVE-<(53rY!x7nN2hl!KAbV){dJ$ou{dIe3qwA6u}+E{ zlSCRQlYklsvj`m!4Uy&a zj*0W^;SI+Ra01F}Mll`z5KtfHA;nQD;F{5mV@dnGQ zks2}uNx46QooV8oGp>Fink;9IQ^4pi+y||hcHsM_${=>*kd|*dKZ+Geklc*`I { + if (data.isGroupChat) { + return data.startTime && data.endTime + } else { + return data.userQueryResponse + } + }, + { + message: + "isGroupChat 为 true 时需要 startTime 和 endTime,为 false 时需要 userQueryResponse", + path: ["isGroupChat"], + } + ) diff --git a/constant/card.ts b/constant/card.ts index d7519eb..b96d4b9 100644 --- a/constant/card.ts +++ b/constant/card.ts @@ -1,130 +1,5 @@ import { cardComponent } from "@egg/lark-msg-tool" -const groupSelector = { - config: { - update_multi: true, - }, - elements: [ - { - tag: "markdown", - content: "请选择要对话的群聊", - }, - { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { - tag: "plain_text", - content: "请选择群名称", - }, - value: { - cardGroup: "groupAgent", - cardName: "groupSelector", - }, - options: "${groupOptions}", - }, - ], - }, - { - tag: "hr", - }, - cardComponent.commonNote, - ], - header: cardComponent.pendingHeader, -} - -const functionSelector = { - config: { - update_multi: true, - }, - elements: [ - { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { - tag: "plain_text", - content: "请选择功能", - }, - value: { - cardGroup: "groupAgent", - cardName: "functionSelector", - }, - options: "${functionOptions}", - }, - ], - }, - { - tag: "hr", - }, - cardComponent.commonNote, - ], - header: cardComponent.pendingHeader, -} - -const timeScopeSelector = { - config: { - update_multi: true, - }, - elements: [ - { - tag: "markdown", - content: "请选择时间范围", - }, - { - tag: "action", - actions: [ - { - tag: "button", - text: { - tag: "plain_text", - content: "过去一天", - }, - type: "default", - value: { - cardGroup: "groupAgent", - cardName: "timeScopeSelector", - timeScope: "1", - }, - }, - { - tag: "button", - text: { - tag: "plain_text", - content: "过去三天", - }, - type: "default", - value: { - cardGroup: "groupAgent", - cardName: "timeScopeSelector", - timeScope: "3", - }, - }, - { - tag: "button", - text: { - tag: "plain_text", - content: "过去七天", - }, - type: "default", - value: { - cardGroup: "groupAgent", - cardName: "timeScopeSelector", - timeScope: "7", - }, - }, - ], - }, - { - tag: "hr", - }, - cardComponent.commonNote, - ], - header: cardComponent.pendingHeader, -} - const resultReport = { config: { update_multi: true, @@ -172,18 +47,8 @@ const autoReport = { }, } -export const functionOptionList = [ - { - id: "summary-qwen-72b-instruct-int4", - name: "总结消息", - }, -] - const cardMap = { - functionSelector, - timeScopeSelector, resultReport, - groupSelector, autoReport, } diff --git a/controller/groupAgent/agent.ts b/controller/groupAgent/agent.ts new file mode 100644 index 0000000..3eebb34 --- /dev/null +++ b/controller/groupAgent/agent.ts @@ -0,0 +1,64 @@ +import { Context } from "../../types" +import llm from "../../utils/llm" +import getChatHistory from "./chatHistory" + +const agent = async (ctx: Context.Data) => { + const { + logger, + requestId, + larkCard, + larkService, + larkBody: { messageId, msgText, chatId, mentions }, + } = ctx + const cardGender = larkCard.child("groupAgent") + // 回复一个loading的卡片 + const { + data: { message_id }, + } = await larkService.message.replyCard( + messageId, + cardGender.genPendingCard("分析中,请稍等...") + ) + const updateCard = (content: any) => + larkService.message.update(message_id, content) + + // 使用大模型解析用户输入 + const { startTime, endTime } = await llm.timeParser(msgText, requestId) + logger.info(`Parsed time: startTime: ${startTime}, endTime: ${endTime}`) + // 更新卡片 + updateCard(cardGender.genPendingCard("正在爬楼中,请稍等...")) + // 获取聊天记录 + const chatHistory = await getChatHistory(ctx, { + chatId, + startTime, + endTime, + mentions, + }) + // 如果没有聊天记录,返回错误信息 + if (chatHistory.length === 0) { + logger.info("No chat history found") + return await updateCard(cardGender.genErrorCard("未找到聊天记录")) + } + logger.debug(`Chat history: ${JSON.stringify(chatHistory)}`) + + // 调用大模型 + try { + const llmRes = await llm.invoke( + "groupAgent", + { + userInput: msgText, + chatHistory: JSON.stringify(chatHistory), + time: new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }), + }, + requestId + ) + logger.info( + `LLM invoked successfully, see detail: http://langfuse.ai.srv/project/cm1j2tkj9001gukrgdvc1swuw/sessions/${requestId}` + ) + await updateCard(cardGender.genSuccessCard(llmRes)) + } catch (error: any) { + logger.error(`Failed to invoke llm: ${error.message}`) + await updateCard(cardGender.genErrorCard("LLM调用失败: " + error.message)) + } +} + +export default agent diff --git a/controller/groupAgent/chatHistory.ts b/controller/groupAgent/chatHistory.ts index 50d5e0b..61e2088 100644 --- a/controller/groupAgent/chatHistory.ts +++ b/controller/groupAgent/chatHistory.ts @@ -46,15 +46,13 @@ const getChatHistory = async ( { larkService, logger }: Context.Data, { chatId, - timeScope, startTime, endTime, mentions: targetUsers, }: { chatId: string - timeScope?: string - startTime?: string - endTime?: string + startTime: string + endTime: string mentions?: LarkEvent.Mention[] } ): Promise => { @@ -63,27 +61,15 @@ const getChatHistory = async ( // 上海时区的偏移量(UTC+8,以分钟为单位) const shanghaiTimezoneOffset = -8 * 60 - let startTimeTimestamp: number - let endTimeTimestamp: number - - if (startTime && endTime) { - // 将startTime和endTime转换为时间戳,并调整为上海时区 - startTimeTimestamp = Math.round( - new Date(startTime).getTime() / 1000 + - (shanghaiTimezoneOffset - serverTimezoneOffset) * 60 - ) - endTimeTimestamp = Math.round( - new Date(endTime).getTime() / 1000 + - (shanghaiTimezoneOffset - serverTimezoneOffset) * 60 - ) - } else { - // 计算时间戳,调整为上海时区 - endTimeTimestamp = - Math.round(new Date().getTime() / 1000) + + // 将startTime和endTime转换为时间戳,并调整为上海时区 + const startTimeTimestamp = Math.round( + new Date(startTime).getTime() / 1000 + (shanghaiTimezoneOffset - serverTimezoneOffset) * 60 - startTimeTimestamp = endTimeTimestamp - Number(timeScope) * 24 * 60 * 60 - } - + ) + const endTimeTimestamp = Math.round( + new Date(endTime).getTime() / 1000 + + (shanghaiTimezoneOffset - serverTimezoneOffset) * 60 + ) // 获取群聊中的历史记录 const { data: chatHistory } = await larkService.message.getHistory( chatId, diff --git a/controller/groupAgent/index.ts b/controller/groupAgent/index.ts index f3feea8..92166e6 100644 --- a/controller/groupAgent/index.ts +++ b/controller/groupAgent/index.ts @@ -1,8 +1,8 @@ -import manual from "./manual" +import agent from "./agent" import report from "./report" const groupAgent = { - manual, + agent, report, } diff --git a/controller/groupAgent/manual.ts b/controller/groupAgent/manual.ts deleted file mode 100644 index bf42724..0000000 --- a/controller/groupAgent/manual.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { genCardOptions, LarkEvent } from "@egg/lark-msg-tool" - -import { functionOptionList } from "../../constant/card" -import { Context, LarkServer } from "../../types" -import llm from "../../utils/llm" -import getChatHistory from "./chatHistory" - -/** - * 生成群组选择器 - * @param ctx - 上下文数据 - * @param innerList - 内部群组列表 - * @param commonVal - 通用值 - */ -const genGroupSelector = ( - { larkCard }: Context.Data, - innerList: LarkServer.ChatGroupData[], - commonVal: Record = {} -) => { - const cardGender = larkCard.child("groupAgent") - // 组织群组数据 - const groupOptions = genCardOptions( - innerList.reduce( - (acc, item) => { - acc[item.name] = `${item.chat_id}|${item.name}` - return acc - }, - {} as Record - ) - ) - return cardGender.genCard("groupSelector", { - groupOptions, - ...commonVal, - }) -} - -/** - * 生成功能选择器 - * @param ctx - 上下文数据 - * @param commonVal - 通用值 - */ -const genFunctionSelector = ( - { larkCard }: Context.Data, - commonVal: Record = {} -) => { - const cardGender = larkCard.child("groupAgent") - // 组织功能数据 - const functionOptions = genCardOptions( - functionOptionList.reduce( - (acc, item) => { - acc[item.name] = `${item.id}|${item.name}` - return acc - }, - {} as Record - ) - ) - return cardGender.genCard("functionSelector", { - functionOptions, - ...commonVal, - }) -} - -/** - * 生成时间范围选择器 - * @param ctx - 上下文数据,包含body和larkService - */ -const genTimeScopeSelector = ( - { larkCard }: Context.Data, - commonVal: Record = {} -) => { - return larkCard.child("groupAgent").genCard("timeScopeSelector", commonVal) -} - -const sendGroupReport = async ( - ctx: Context.Data, - messageId: string, - { - chatId, - chatName, - functionId, - functionName, - timeScope, - startTime, - endTime, - mentions, - }: { - chatId: string - chatName: string - functionId: string - functionName: string - timeScope?: string - startTime?: string - endTime?: string - mentions?: LarkEvent.Mention[] - } -) => { - const { larkService, logger, larkCard, requestId } = ctx - const cardGender = larkCard.child("groupAgent") - const updateCard = (content: any) => - larkService.message.update(messageId, content) - // action需要返回loading的消息,event需要主动update卡片,所以loading就放外边了 - // 记录发送loading消息后的时间戳 - const processStart = Date.now() - // 获取聊天记录 - const chatHistory = await getChatHistory(ctx, { - chatId, - timeScope, - startTime, - endTime, - mentions, - }) - // 如果没有历史记录则返回错误消息 - if (chatHistory.length === 0) { - logger.error("Chat history is empty") - await updateCard(cardGender.genErrorCard("未找到聊天记录")) - return - } - logger.debug(`Chat history: ${JSON.stringify(chatHistory)}`) - - try { - const llmRes = await llm.invoke( - functionId, - { - chatHistory: JSON.stringify(chatHistory), - time: new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }), - }, - requestId - ) - // 记录大模型返回结果后的时间戳 - const processEnd = Date.now() - // 计算时间差并存储在processingTime变量中,以秒为单位 - const processingTime = ((processEnd - processStart) / 1000).toFixed(2) - logger.info(`LLM takes time: ${processingTime}s, result: ${llmRes}`) - - // 动态生成content内容 - const timeRange = timeScope - ? `总结天数:**${timeScope}**` - : `时间范围:**${startTime}** 至 **${endTime}**` - const mentionsList = mentions - ? `圈选的用户:\n${mentions - .filter((v) => v.id.user_id) - .map((mention: any) => `- ${mention.name}`) - .join("\n")}` - : "" - - const content = `群聊:**${chatName}**,功能:**${functionName}**,${timeRange}\n\n${mentionsList}\n\n以下内容由AI模型生成,耗时:**${processingTime}s**` - - // 更新消息卡片 - await larkService.message.update( - messageId, - cardGender.genCard("resultReport", { - content, - llmRes, - }) - ) - } catch (error: any) { - logger.error(`LLM error: ${error.message}`) - await larkService.message.update( - messageId, - cardGender.genErrorCard("LLM调用失败: " + error.message) - ) - } -} - -/** - * 解析用户输入中的值并发送对应的表单卡片 - * @param ctx - 上下文数据 - * @param innerList - 内部群组列表 - */ -const parseGroupAgentQuery = async ( - ctx: Context.Data, - innerList: LarkServer.ChatGroupData[], - mentions?: LarkEvent.Mention[] -) => { - // TODO:处理在群聊里的情况,不用获取群组名称 - const { - larkBody: { msgText, chatId: rawChatId }, - larkService, - larkCard, - logger, - requestId, - } = ctx - const cardGender = larkCard.child("groupAgent") - - // 发送一个loading的消息 - const { - data: { message_id }, - } = await larkService.message.sendCard2Chat( - rawChatId, - cardGender.genPendingCard("分析中,请稍等...") - ) - - const updateCard = (content: any) => - larkService.message.update(message_id, content) - - // 组织群组数据 - const groupInfo = JSON.stringify( - innerList.map((v) => ({ - name: v.name, - id: v.chat_id, - })) - ) - // 获取功能信息 - const functionInfo = JSON.stringify(functionOptionList) - - // 使用大模型解析用户输入 - const { chatId, chatName, functionName, functionId, startTime, endTime } = - await llm.parseGroupAgentQuery(msgText, groupInfo, functionInfo, requestId) - logger.info( - `Parsed group agent query: chatId: ${chatId}, chatName: ${chatName}, functionName: ${functionName}, functionId: ${functionId}, startTime: ${startTime}, endTime: ${endTime}` - ) - - // 判断顺序是 群组 -> 功能 -> 时间范围 - - // 返回群组选择器,其他的值往里边丢就行 - if (!chatId || !chatName) { - logger.info("Send group selector") - updateCard( - genGroupSelector(ctx, innerList, { - functionName, - functionId, - startTime, - endTime, - mentions, - }) - ) - return - } - // 返回功能选择器,其他的值往里边丢就行 - if (!functionId || !functionName) { - logger.info("Send function selector") - updateCard( - genFunctionSelector(ctx, { - chatId, - chatName, - startTime, - endTime, - mentions, - }) - ) - return - } - // 返回时间范围选择器,其他的值往里边丢就行 - if (!startTime || !endTime) { - logger.info("Send time scope selector") - updateCard( - genTimeScopeSelector(ctx, { - chatId, - chatName, - functionId, - functionName, - mentions, - }) - ) - return - } - - logger.info("Send group report") - // 设置齐全,返回结果报告 - updateCard(cardGender.genPendingCard("正在爬楼中,请稍等...")) - sendGroupReport(ctx, message_id, { - chatId, - chatName, - functionId, - functionName, - startTime, - endTime, - mentions, - }) -} - -/** - * 管理事件消息 - * @param ctx - 上下文数据 - */ -const manageEventMsg = async (ctx: Context.Data) => { - const { - larkBody: { msgText, chatType, chatId: rawChatId, mentions }, - larkService, - logger, - } = ctx - - // 获取群组信息 - const { data: innerList } = await larkService.chat.getInnerList() - logger.info(`Inner list: ${JSON.stringify(innerList)}`) - const sendCard = (content: string) => - larkService.message.sendCard2Chat(rawChatId, content) - - // 去过去掉所有非必要的信息为空的话 - if (msgText.replace("/groupchat", "").replaceAll(" ", "") === "") { - // 私聊发送正常的群组选择器 - if (chatType === "p2p") { - logger.info("Send group selector to p2p chat") - await sendCard(genGroupSelector(ctx, innerList, { mentions })) - return - } - // 如果是群聊,获取群聊名称并发送功能 - const { - data: { name: chatName }, - } = await larkService.chat.getChatInfo(rawChatId) - logger.info(`Send function selector to group chat: ${chatName}`) - await sendCard(genFunctionSelector(ctx, { chatName, mentions })) - return - } - - logger.info(`User input: ${msgText}, chatType: ${chatType}, use llm to parse`) - // 用户有输入,使用大模型进行解析发送对应卡片 - await parseGroupAgentQuery(ctx, innerList, mentions) - return -} - -/** - * 管理Action消息 - * @param ctx - 上下文数据 - */ -const manageActionMsg = async (ctx: Context.Data) => { - const { - larkBody: { actionOption, actionValue, messageId }, - larkCard, - logger, - } = ctx - const cardGender = larkCard.child("groupAgent") - logger.debug(`Action option: ${JSON.stringify(actionOption)}`) - logger.debug(`Action value: ${JSON.stringify(actionValue)}`) - let { chatId, chatName, functionId, functionName } = actionValue - const { timeScope, startTime, endTime, cardName, mentions } = actionValue - // 如果是群组选择器返回值 - if (cardName === "groupSelector") { - const [newChatId, newChatName] = (actionOption ?? "").split("|") - if (!newChatId || !newChatName) { - logger.error( - `Invalid targetChatId or targetChatName: ${JSON.stringify(actionOption)}` - ) - return cardGender.genErrorCard("Invalid targetChatId or targetChatName") - } - chatId = newChatId - chatName = newChatName - } - // 如果是功能选择器返回值 - if (cardName === "functionSelector") { - const [newFunctionId, newFunctionName] = (actionOption ?? "").split("|") - if (!newFunctionId || !newFunctionName) { - logger.error( - `Invalid functionId or functionName: ${JSON.stringify(actionOption)}` - ) - return cardGender.genErrorCard("Invalid functionId or functionName") - } - functionId = newFunctionId - functionName = newFunctionName - } - // 时间返回的返回值就会带在timeScope里,不需要再处理 - // 理论上来说,这里的chatId, chatName肯定是有值的,不需要再判断 - // 判断是否需要返回功能选择器 - if (!functionId || !functionName) { - logger.info("Send function selector") - return genFunctionSelector(ctx, { - chatId, - chatName, - startTime, - endTime, - timeScope, - mentions, - }) - } - - // 判断是否需要返回时间范围选择器 - if (!(timeScope || (startTime && endTime))) { - logger.info("Send time scope selector") - return genTimeScopeSelector(ctx, { - chatId, - chatName, - functionId, - functionName, - mentions, - }) - } - - logger.info("Send group report") - // 设置齐全,返回结果报告 - sendGroupReport(ctx, messageId, { - chatId, - chatName, - functionId, - functionName, - timeScope, - startTime, - endTime, - mentions, - }) - return cardGender.genPendingCard("正在爬楼中,请稍等...") -} - -/** - * 群组Agent的主入口 - * @param ctx - 上下文数据 - */ -const manual = async (ctx: Context.Data) => { - const { - larkBody: { isEvent, isAction }, - logger, - } = ctx - try { - // 如果是Event,则解析自然语言并发送对应的卡片 - if (isEvent) return await manageEventMsg(ctx) - // 如果是Action,则取出用户选的值并判断是否需要继续发送表单卡片或者开始大模型推理 - if (isAction) return await manageActionMsg(ctx) - } catch (e: any) { - logger.error(`Group agent error: ${e.message}`) - return ctx.larkCard.child("groupAgent").genErrorCard("Group agent error") - } -} - -export default manual diff --git a/package.json b/package.json index 2c7443c..37004a0 100644 --- a/package.json +++ b/package.json @@ -39,13 +39,13 @@ "@egg/hooks": "^1.2.0", "@egg/lark-msg-tool": "^1.17.0", "@egg/logger": "^1.6.0", - "@egg/net-tool": "^1.16.1", + "@egg/net-tool": "^1.19.0", "@egg/path-tool": "^1.4.1", "@langchain/core": "^0.3.19", "@langchain/openai": "^0.3.14", "@prisma/client": "^5.22.0", "joi": "^17.13.3", - "langfuse-langchain": "^3.31.0", + "langfuse-langchain": "^3.31.1", "node-schedule": "^2.1.1", "p-limit": "^6.1.0", "pocketbase": "^0.21.5", diff --git a/routes/bot/actionMsg.ts b/routes/bot/actionMsg.ts index e901a79..497648a 100644 --- a/routes/bot/actionMsg.ts +++ b/routes/bot/actionMsg.ts @@ -1,9 +1,6 @@ -import groupAgent from "../../controller/groupAgent" import { Context } from "../../types" -const GROUP_MAP = { - groupAgent: groupAgent.manual, -} +const GROUP_MAP = {} /** * 处理点击事件 @@ -19,7 +16,7 @@ const manageAction = async (ctx: Context.Data) => { } logger.info(`Got lark action cardGroup: ${cardGroup}`) if (!cardGroup) return - const func = GROUP_MAP[cardGroup] + const func = GROUP_MAP[cardGroup] as (ctx: Context.Data) => Promise if (!func) return return func(ctx) } diff --git a/routes/bot/eventMsg.ts b/routes/bot/eventMsg.ts index efbd03c..19028e2 100644 --- a/routes/bot/eventMsg.ts +++ b/routes/bot/eventMsg.ts @@ -136,14 +136,6 @@ const manageCMDMsg = (ctx: Context.Data) => { } } - // michat专属功能 - if (app === "michat") { - // 帮助 - logger.info(`bot command is /help, chatId: ${chatId}`) - manageHelpMsg(ctx, "miChatGuide") - return - } - // 小煎蛋专属功能 if (app === "egg") { // CI监控 @@ -173,13 +165,6 @@ const manageCMDMsg = (ctx: Context.Data) => { return } - // 选择群组信息 - if (msgText.startsWith("/g")) { - logger.info(`bot command is /groupchat, chatId: ${chatId}`) - groupAgent.manual(ctx) - return - } - // 帮助 if (msgText === "/help") { logger.info(`bot command is /help, chatId: ${chatId}`) @@ -188,6 +173,12 @@ const manageCMDMsg = (ctx: Context.Data) => { } } + // 如果是群聊,使用groupAgent兜底 + if (isInGroup) { + groupAgent.agent(ctx) + return + } + return } diff --git a/test/groupAgent/agent.http b/test/groupAgent/agent.http new file mode 100644 index 0000000..35d6703 --- /dev/null +++ b/test/groupAgent/agent.http @@ -0,0 +1,4 @@ +POST http://localhost:3000/bot?app=egg HTTP/1.1 +content-type: application/json + +{"schema":"2.0","header":{"event_id":"c94518fbcb9d66cc93b4144cc69e4f0c","token":"tV9djUKSjzVnekV7xTg2Od06NFTcsBnj","create_time":"1732612280153","event_type":"im.message.receive_v1","tenant_key":"2ee61fe50f4f1657","app_id":"cli_a1eff35b43b89063"},"event":{"message":{"chat_id":"oc_8c789ce8f4ecc6695bb63ca6ec4c61ea","chat_type":"group","content":"{\"text\":\"@_user_1 今天的重点消息是什么\"}","create_time":"1732612279943","mentions":[{"id":{"open_id":"ou_032f507d08f9a7f28b042fcd086daef5","union_id":"on_7111660fddd8302ce47bf1999147c011","user_id":""},"key":"@_user_1","name":"小煎蛋","tenant_key":"2ee61fe50f4f1657"}],"message_id":"om_4ab57cb6e889eeb81ca061b137238189","message_type":"text","update_time":"1732612279943"},"sender":{"sender_id":{"open_id":"ou_470ac13b8b50fc472d9d8ee71e03de26","union_id":"on_9dacc59a539023df8b168492f5e5433c","user_id":"zhaoyingbo"},"sender_type":"user","tenant_key":"2ee61fe50f4f1657"}}} \ No newline at end of file diff --git a/test/parseGroupAgentQuery.test.ts b/test/parseGroupAgentQuery.test.ts new file mode 100644 index 0000000..86cfd56 --- /dev/null +++ b/test/parseGroupAgentQuery.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" + +import llm from "../utils/llm" +describe("timeParser", () => { + const testCases = [ + { + input: "过去五天赵英博说了什么", + expected: { s: "2024-11-26 00:00:00", e: "2024-11-30 23:59:59" }, + }, + { + input: "昨天的会议记录", + expected: { s: "2024-11-30 00:00:00", e: "2024-11-30 23:59:59" }, + }, + { + input: "上周的销售数据", + expected: { s: "2024-11-18 00:00:00", e: "2024-11-24 23:59:59" }, + }, + { + input: "今天的天气", + expected: { s: "2024-12-01 00:00:00", e: "2024-12-01 23:59:59" }, + }, + { + input: "上个月的财务报表", + expected: { s: "2024-11-01 00:00:00", e: "2024-11-30 23:59:59" }, + }, + { + input: "今年的计划", + expected: { s: "2024-01-01 00:00:00", e: "2024-12-31 23:59:59" }, + }, + { + input: "明天的安排", + expected: { s: "2024-12-02 00:00:00", e: "2024-12-02 23:59:59" }, + }, + { + input: "下周的会议", + expected: { s: "2024-12-02 00:00:00", e: "2024-12-08 23:59:59" }, + }, + { + input: "下个月的活动", + expected: { s: "2024-12-01 00:00:00", e: "2024-12-31 23:59:59" }, + }, + { + input: "明年的目标", + expected: { s: "2025-01-01 00:00:00", e: "2025-12-31 23:59:59" }, + }, + ] + + testCases.forEach(({ input, expected }, index) => { + test(`Test case ${index + 1}: ${input}`, async () => { + const result = await llm.timeParser(input, `testRequestId${index}`) + expect(result).toEqual(expected) + }) + }) +}) diff --git a/test/parseGroupAgentQuery.ts b/test/parseGroupAgentQuery.ts index 6f4d668..ad93d04 100644 --- a/test/parseGroupAgentQuery.ts +++ b/test/parseGroupAgentQuery.ts @@ -1,25 +1,5 @@ import llm from "../utils/llm" -const groupInfo = JSON.stringify([ - { - id: "oc_ef98c2a9229657f99d4ef573a30fe91c", - name: "MIAI-FE 人工智能部-前端组", - }, - { - id: "oc_433b1cb7a9dbb7ebe70a4e1a59cb8bb1", - name: "方糖の家", - }, -]) +const userInput = "下个月份的活动" -const functionInfo = JSON.stringify([ - { - id: "summary-qwen-72b-instruct-int4", - name: "总结消息", - }, -]) - -const userInput = "你好" - -llm - .parseGroupAgentQuery(userInput, groupInfo, functionInfo, "localTest") - .then(console.log) +llm.timeParser(userInput, "localTest").then(console.log) diff --git a/utils/llm/index.ts b/utils/llm/index.ts index 86441c0..c3803a2 100644 --- a/utils/llm/index.ts +++ b/utils/llm/index.ts @@ -1,68 +1,81 @@ import { PromptTemplate } from "@langchain/core/prompts" import { z } from "zod" +import { adjustTimeRange, getTimeRange } from "../time" import { getLangfuse, getModel } from "./base" -const groupAgentConfig = z.object({ - chatId: z.string().describe("群聊ID"), - chatName: z.string().describe("群聊名称"), - functionId: z.string().describe("功能ID"), - functionName: z.string().describe("功能名称"), - startTime: z.string().describe("开始时间,格式为 YYYY-MM-DD HH:mm:ss"), - endTime: z.string().describe("结束时间,格式为 YYYY-MM-DD HH:mm:ss"), -}) - /** - * 解析GroupAgent用户输入 + * 解析timeParser用户输入 + * 如果解析失败,则返回过去三天的时间范围 * @param userInput 用户输入 - * @param groupInfo 群聊信息 - * @param functionInfo 功能信息 * @param requestId 请求ID * @returns */ -const parseGroupAgentQuery = async ( - userInput: string, - groupInfo: string, - functionInfo: string, - requestId: string -) => { +const timeParser = async (userInput: string, requestId: string) => { const { langfuseHandler } = await getLangfuse( "parseGroupAgentQuery", requestId ) const model = await getModel() - const structuredLlm = model.withStructuredOutput(groupAgentConfig, { - name: "groupAgent", - }) - return await structuredLlm.invoke( - ` - 当前时间为:${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} - 所有可用群组信息:${groupInfo} - 所有支持功能信息:${functionInfo} - 你是一个专业的语义解析工程师,给定以下用户输入,帮我解析出群聊ID、群聊名称、功能ID、功能名称、开始时间和结束时间。 - 默认功能为总结消息,时间如果用户没有输入则留空 - 返回格式定义为: - \`\`\` + const structuredLlm = model.withStructuredOutput( + z.object({ + s: z.string().describe("开始时间,格式为 YYYY-MM-DD HH:mm:ss"), + e: z.string().describe("结束时间,格式为 YYYY-MM-DD HH:mm:ss"), + }), { - "chatId": "oc_ef98c2a9229657f99d4ef573a30fe91c", - "chatName": "MIAI-FE 人工智能部-前端组", - "functionId": "summary-qwen-72b-instruct-int4", - "functionName": "总结消息", - "startTime": "2022-01-01 00:00:00", - "endTime": "2022-01-01 23:59:59" - } - \`\`\` - 如果不包含对应内容,请返回空值。 - - 用户输入: - \`\`\` - ${userInput.replaceAll("`", " ")} - \`\`\` - `, - { - callbacks: [langfuseHandler], + name: "timeParser", } ) + + const invokeParser = async () => { + try { + return await structuredLlm.invoke( + ` + 当前时间为:${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} + 请解析以下用户输入,提取出开始时间和结束时间,默认为过去三天 + 除非用户指定到了分钟,开始时间为当天的 00:00:00,结束时间为当天的 23:59:59 + 请将解析结果严格按照以下格式返回: + \`\`\`ts + interface GroupAgent { + s: string // 开始时间,格式为 YYYY-MM-DD HH:mm:ss + e: string // 结束时间,格式为 YYYY-MM-DD HH:mm:ss + } + \`\`\` + + 用户输入: + \`\`\` + ${userInput.replaceAll("`", " ")} + \`\`\` + `, + { + callbacks: [langfuseHandler], + } + ) + } catch { + // 如果解析失败,则返回空字符串 + return { s: "", e: "" } + } + } + + const validateResult = (result: any) => { + const regex = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/ + return regex.test(result.s) && regex.test(result.e) + } + + let result + let attempts = 0 + do { + result = await invokeParser() + attempts++ + } while (!validateResult(result) && attempts < 3) + + // 如果解析失败,则返回过去三天的时间范围 + if (!validateResult(result)) { + return getTimeRange("threeDays") + } + + // 解析成功,调整时间范围 + return adjustTimeRange(result.s, result.e) } /** @@ -98,7 +111,7 @@ const invoke = async ( } const llm = { - parseGroupAgentQuery, + timeParser, invoke, } diff --git a/utils/time.ts b/utils/time.ts index a15a426..adf7a49 100644 --- a/utils/time.ts +++ b/utils/time.ts @@ -1,30 +1,78 @@ +/** + * 格式化日期对象为字符串 + * @param {Date} date - 日期对象 + * @returns {string} - 格式化后的日期字符串,格式为 YYYY-MM-DD HH:mm:ss + */ +const formatDate = (date: Date) => { + const pad = (num: number) => (num < 10 ? "0" + num : num) + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +} + +/** + * 将日期字符串解析为日期对象 + * @param {string} dateStr - 日期字符串,格式为 YYYY-MM-DD HH:mm:ss + * @returns {Date} - 解析后的日期对象 + */ +const parseDate = (dateStr: string) => { + const [datePart, timePart] = dateStr.split(" ") + const [year, month, day] = datePart.split("-").map(Number) + const [hours, minutes, seconds] = timePart.split(":").map(Number) + return new Date(year, month - 1, day, hours, minutes, seconds) +} + /** * 获取指定时间范围的开始时间和结束时间 - * @param {("daily" | "weekly")} timeScope - 时间范围,可以是 "daily" 或 "weekly" + * @param {("daily" | "weekly" | "threeDays")} timeScope - 时间范围,可以是 "daily"、"weekly" 或 "threeDays" * @returns {{ startTime: string, endTime: string }} - 格式化后的开始时间和结束时间 */ -export const getTimeRange = (timeScope: "daily" | "weekly") => { - const formatDate = (date: Date) => { - const pad = (num: number) => (num < 10 ? "0" + num : num) - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` - } - +export const getTimeRange = (timeScope: "daily" | "weekly" | "threeDays") => { const now = new Date() const startTime = new Date(now) - if (timeScope === "daily") { - startTime.setHours(0, 0, 0, 0) - } else { + startTime.setHours(0, 0, 0, 0) + const endTime = new Date(now) + endTime.setHours(23, 59, 59, 999) + + if (timeScope === "weekly") { const day = now.getDay() const diff = day === 0 ? 6 : day - 1 // adjust when day is sunday startTime.setDate(now.getDate() - diff) - startTime.setHours(0, 0, 0, 0) + } else if (timeScope === "threeDays") { + startTime.setDate(now.getDate() - 2) } - const endTime = new Date(now) - endTime.setHours(23, 59, 59, 999) - return { startTime: formatDate(startTime), endTime: formatDate(endTime), } } + +/** + * 调整时间范围 + * 如果开始时间晚于结束时间,则交换两者 + * 如果开始时间和结束时间都晚于当前时间,则调整为过去三天 + * 如果开始时间早于当前时间且结束时间晚于当前时间,则将结束时间调整为当前时间 + * @param {string} startTime - 开始时间,格式为 YYYY-MM-DD HH:mm:ss + * @param {string} endTime - 结束时间,格式为 YYYY-MM-DD HH:mm:ss + * @returns {{ startTime: string, endTime: string }} - 调整后的开始时间和结束时间 + */ +export const adjustTimeRange = (startTime: string, endTime: string) => { + const now = new Date() + let start = parseDate(startTime) + let end = parseDate(endTime) + + if (start > end) { + ;[start, end] = [end, start] + } + + if (start > now && end > now) { + return getTimeRange("threeDays") + } else if (start < now && end > now) { + end = now + end.setHours(23, 59, 59, 999) + } + + return { + startTime: formatDate(start), + endTime: formatDate(end), + } +}