150 lines
6.6 KiB
TypeScript
150 lines
6.6 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
|
import { LlmChatClient } from '../llm/llm-chat.client';
|
|
import { KnowledgeRetrievalService } from '../llm/knowledge-retrieval.service';
|
|
import { WecomBotActionsService } from './wecom-bot-actions.service';
|
|
import { parseWecomToolLine, sanitizeWecomUserReply } from './wecom-bot-reply.util';
|
|
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
|
import { wecomBotHasPermission } from './wecom-bot.types';
|
|
|
|
const DEFAULT_SYSTEM = [
|
|
'你是杜康好客企业内部助手。',
|
|
'优先依据提供的知识库内容回答;知识库未覆盖时如实说明不确定。',
|
|
'不要编造订单号、金额、权限;涉及写操作请引导用户使用指令(如「帮助」)。',
|
|
'回答简洁,使用中文。',
|
|
].join('');
|
|
|
|
const TOOL_INSTRUCTION = [
|
|
'当用户需要查询业务数据时,你必须在回复的第一行输出工具指令(仅一行,用户不可见后续会过滤):',
|
|
'格式:`TOOL <工具名> [参数]`',
|
|
'可用工具(按权限):',
|
|
'- order_read <订单号>',
|
|
'- delivery_read <单号>',
|
|
'- store_read <关键词> · redeem_read <核销单号或门店>',
|
|
'- user_read <用户号>',
|
|
'- support_tickets_open · support_ticket_read [工单号]',
|
|
'- support_ticket_approve <工单号> · support_ticket_reject <工单号> <理由>',
|
|
'- finance_store_bill [门店] · finance_partner_bill · finance_winery_bill · finance_logistics_bill',
|
|
'- finance_payout · finance_withdrawal',
|
|
'- dev_plan_tasks [状态] · dev_plan_versions [版本号]',
|
|
'- dev_plan_task_create <BUG|REQUIREMENT|OPTIMIZATION> <描述>',
|
|
'- dev_plan_task_update_status <任务编号> <TODO|DEVELOPED|RELEASED>',
|
|
'- dev_plan_version_create <版本号>',
|
|
'- dev_plan_version_update_status <版本号> <PENDING|IN_PROGRESS|TESTING|RELEASED>',
|
|
'- dev_plan_version_link_tasks <版本号> <任务编号1,任务编号2>',
|
|
'- server_logs [关键词] · handbook_read [关键词]',
|
|
'禁止输出 JSON、api 字段、「请稍等正在检索」等占位话术。',
|
|
'若仅需解释概念、无需查库,第一行写 `ANSWER` 后直接回答。',
|
|
].join('\n');
|
|
|
|
@Injectable()
|
|
export class WecomBotAiService {
|
|
private readonly logger = new Logger(WecomBotAiService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly llm: LlmChatClient,
|
|
private readonly kb: KnowledgeRetrievalService,
|
|
private readonly actions: WecomBotActionsService,
|
|
) {}
|
|
|
|
async replyIfConfigured(
|
|
bot: WecomBotRuntimeConfig,
|
|
userText: string,
|
|
wecomUserId: string,
|
|
): Promise<string | null> {
|
|
if (!bot.aiEnabled || !bot.llmConfigId) return null;
|
|
|
|
const cfg = await this.prisma.llmApiConfig.findUnique({
|
|
where: { id: BigInt(bot.llmConfigId) },
|
|
});
|
|
if (!cfg?.enabled) {
|
|
return '已开启 AI,但绑定的语言模型未启用或已删除。请在 HQ「企微机器人」检查配置。';
|
|
}
|
|
|
|
let kbBlock = '';
|
|
if (bot.knowledgeBaseId) {
|
|
try {
|
|
kbBlock = await this.kb.buildContext(BigInt(bot.knowledgeBaseId), userText);
|
|
} catch (e) {
|
|
this.logger.warn(`kb retrieve failed: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
const systemParts = [
|
|
cfg.systemPrompt?.trim() || DEFAULT_SYSTEM,
|
|
this.buildToolHint(bot),
|
|
TOOL_INSTRUCTION,
|
|
kbBlock ? `\n\n以下为知识库检索片段:\n${kbBlock}` : '',
|
|
];
|
|
|
|
try {
|
|
this.logger.log(
|
|
`wecom ai request bot=${bot.key} user=${wecomUserId} text=${userText.slice(0, 80)}`,
|
|
);
|
|
const raw = await this.llm.chat({
|
|
baseUrl: cfg.baseUrl,
|
|
apiKey: cfg.apiKey,
|
|
model: cfg.modelName,
|
|
temperature: cfg.temperature != null ? Number(cfg.temperature) : 0.3,
|
|
maxTokens: cfg.maxTokens ?? 1024,
|
|
messages: [
|
|
{ role: 'system', content: systemParts.join('') },
|
|
{ role: 'user', content: userText },
|
|
],
|
|
});
|
|
|
|
const tool = parseWecomToolLine(raw);
|
|
if (tool) {
|
|
this.logger.log(
|
|
`wecom ai tool=${tool.name} bot=${bot.key} user=${wecomUserId} args=${tool.args.slice(0, 80)}`,
|
|
);
|
|
try {
|
|
const result = await this.actions.runTool(bot, wecomUserId, tool.name, tool.args);
|
|
return result;
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
this.logger.warn(`wecom ai tool failed ${tool.name}: ${msg}`);
|
|
return `查询失败:${msg}`;
|
|
}
|
|
}
|
|
|
|
const answerMatch = raw.match(/^ANSWER\s+([\s\S]*)/i);
|
|
if (answerMatch) {
|
|
return sanitizeWecomUserReply(answerMatch[1]);
|
|
}
|
|
|
|
return sanitizeWecomUserReply(raw);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
this.logger.error(`wecom ai reply failed: ${msg}`);
|
|
return `AI 回复失败:${msg}`;
|
|
}
|
|
}
|
|
|
|
private buildToolHint(bot: WecomBotRuntimeConfig): string {
|
|
const tools: string[] = [];
|
|
if (wecomBotHasPermission(bot, 'order.read')) tools.push('order_read');
|
|
if (wecomBotHasPermission(bot, 'delivery.read')) tools.push('delivery_read');
|
|
if (wecomBotHasPermission(bot, 'store.read')) tools.push('store_read');
|
|
if (wecomBotHasPermission(bot, 'redeem.read')) tools.push('redeem_read');
|
|
if (wecomBotHasPermission(bot, 'user.read')) tools.push('user_read');
|
|
if (wecomBotHasPermission(bot, 'support_ticket.read')) {
|
|
tools.push('support_tickets_open', 'support_ticket_read');
|
|
}
|
|
if (wecomBotHasPermission(bot, 'finance.store_bill.read')) tools.push('finance_store_bill');
|
|
if (wecomBotHasPermission(bot, 'finance.partner_bill.read')) tools.push('finance_partner_bill');
|
|
if (wecomBotHasPermission(bot, 'finance.winery_bill.read')) tools.push('finance_winery_bill');
|
|
if (wecomBotHasPermission(bot, 'finance.logistics_bill.read')) tools.push('finance_logistics_bill');
|
|
if (wecomBotHasPermission(bot, 'finance.payout.read')) tools.push('finance_payout');
|
|
if (wecomBotHasPermission(bot, 'finance.withdrawal.read')) tools.push('finance_withdrawal');
|
|
if (wecomBotHasPermission(bot, 'dev_plan.task.read')) tools.push('dev_plan_tasks');
|
|
if (wecomBotHasPermission(bot, 'dev_plan.version.read')) tools.push('dev_plan_versions');
|
|
if (wecomBotHasPermission(bot, 'dev_plan.version.link_tasks')) tools.push('dev_plan_version_link_tasks');
|
|
if (wecomBotHasPermission(bot, 'server_log.read')) tools.push('server_logs');
|
|
if (wecomBotHasPermission(bot, 'handbook.read')) tools.push('handbook_read');
|
|
if (!tools.length) return '';
|
|
return `\n\n当前机器人可用工具:${tools.join(', ')}`;
|
|
}
|
|
}
|