Commit 5fe4656b by luoqi

fix(wecom): 企微助手多轮记忆 + 患者页面链接 + 纯文字渠道指令

修两个线上问题:
- 上下文断:原来每条消息只传当前一句,无历史 → "该患者"无指代。
  现按「群+人」维护会话记忆(内存,30min TTL,留最近 12 条),整段历史喂助手。
- 没给链接:AssistantService.chat 加 systemExtra,企微渠道注入指令 ——
  纯文字/Markdown(不画 render_artifact)+ 用 plan id 拼 {webBase}/plans/<id> 给 Markdown 链接
  + 多轮指代提示。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 97d06d4c
......@@ -35,6 +35,8 @@ export interface AssistantChatInput {
userToken: string;
modelId?: string;
messages: ModelMessage[];
/** 追加到 system 的渠道特定指令(如企微:纯文字、链接格式、多轮指代)。 */
systemExtra?: string;
abortSignal?: AbortSignal;
}
......@@ -96,7 +98,7 @@ export class AssistantService {
const { model } = this.provider.resolve(input.modelId ?? 'deepseek');
return streamText({
model,
system: SYSTEM_PROMPT,
system: input.systemExtra ? `${SYSTEM_PROMPT}\n\n${input.systemExtra}` : SYSTEM_PROMPT,
messages: input.messages,
tools,
stopWhen: stepCountIs(8), // 防失控:最多 8 步工具循环
......
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import type { ModelMessage } from 'ai';
import AiBot, { generateReqId } from '@wecom/aibot-node-sdk';
import type { WsFrame, TemplateCard } from '@wecom/aibot-node-sdk';
import type { UserRole } from '@pac/types';
......@@ -41,6 +42,20 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy {
private lastChatId?: string;
/** 召回卡深链前缀(本地默认 localhost:3100;线上配真实域名)。 */
private readonly webBase = process.env.PAC_WEB_BASE_URL?.trim() || 'http://localhost:3100';
/** 多轮会话记忆:key = `${chatid}:${userid}` → 最近消息 + 时间戳(内存,带 TTL)。 */
private readonly convos = new Map<string, { msgs: ModelMessage[]; at: number }>();
private readonly CONVO_TTL_MS = 30 * 60 * 1000; // 30 分钟无活动即过期
private readonly CONVO_MAX = 12; // 每会话保留最近 12 条(user/assistant)
/** 企微渠道指令(追加到助手 system):纯文字、链接格式、多轮指代。 */
private get wxSystemExtra(): string {
return [
'你现在在企业微信群聊里(纯文字 Markdown 环境):',
'- 不要调用 render_artifact 画 HTML 卡片(企微渲染不了),直接用简洁文字 / Markdown 回答。',
`- 需要给"患者 / 召回页面链接"时:用工具返回的召回计划 id,拼成 ${this.webBase}/plans/<planId>,以 Markdown 链接给出(如 [查看召回卡](${this.webBase}/plans/xxx))。没有 id 就先用 list_recall_queue / find_patient 拿到。`,
'- 多轮对话:记住上文指代,"该患者 / 他 / 这个人"指本轮对话里刚提到的患者,别再反问是谁。',
].join('\n');
}
// ── 配置 ──
private readonly botId = process.env.PAC_WX_AIBOT_BOT_ID?.trim();
......@@ -114,9 +129,15 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy {
await client.replyStream(frame, streamId, '🔍 正在查…', false);
const token = await this.mintToken(wxUserid);
// 多轮记忆:按 群+人 取历史,追加本轮提问(每人在群里有独立对话线)
const convoKey = `${frame.body.chatid ?? 'p'}:${wxUserid}`;
const history = this.loadConvo(convoKey);
history.push({ role: 'user', content });
const result = await this.assistant.chat({
userToken: token,
messages: [{ role: 'user', content }],
messages: history,
systemExtra: this.wxSystemExtra,
abortSignal: ac.signal,
});
......@@ -144,6 +165,11 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy {
// 工具调用/结果在企微通道不单独展示(文字答案已含结论);render_artifact 的 HTML 忽略。
}
await flush(true);
// 落历史:本轮助手回复进会话记忆,供下轮指代("该患者"=刚推荐的人)
if (full.trim()) {
history.push({ role: 'assistant', content: full.trim() });
this.saveConvo(convoKey, history);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(`企微消息处理失败(user=${wxUserid}): ${msg}`);
......@@ -277,6 +303,25 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy {
return { hostId, tenantId: this.tenantId, clinicIds: [], userId: `wx:${wxUserid}` };
}
/** 取会话历史副本(过期 / 不存在 → 空)。返回副本,避免本轮失败污染已存状态。 */
private loadConvo(key: string): ModelMessage[] {
const e = this.convos.get(key);
if (!e || Date.now() - e.at > this.CONVO_TTL_MS) return [];
return [...e.msgs];
}
/** 存会话历史(只留最近 CONVO_MAX 条;size 过大时顺手清过期项)。 */
private saveConvo(key: string, msgs: ModelMessage[]): void {
const trimmed = msgs.length > this.CONVO_MAX ? msgs.slice(-this.CONVO_MAX) : msgs;
this.convos.set(key, { msgs: trimmed, at: Date.now() });
if (this.convos.size > 500) {
const now = Date.now();
for (const [k, v] of this.convos) {
if (now - v.at > this.CONVO_TTL_MS) this.convos.delete(k);
}
}
}
/** 企微 userid → PAC 客服 token(PoC:统一映射到默认 tenant scope)。 */
private async mintToken(wxUserid: string): Promise<string> {
const hostId = await this.resolveHostId();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment