158 lines
5.8 KiB
TypeScript
158 lines
5.8 KiB
TypeScript
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
|
import type { BatchReviewPreviewItem } from '@dukang/shared-types';
|
|
import { mapSupportTicketTypeToDevPlanTask } from '@dukang/shared-types';
|
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
|
import { LlmChatClient } from '../../integrations/llm/llm-chat.client';
|
|
import { KnowledgeRetrievalService } from '../../integrations/llm/knowledge-retrieval.service';
|
|
import { DevPlanService } from './dev-plan.service';
|
|
|
|
const DEFAULT_REVIEW_PROMPT = [
|
|
'你是杜康好客技术支持工单审核助手。',
|
|
'根据工单内容与知识库片段,给出审批建议:通过(APPROVE)或驳回(REJECT)。',
|
|
'通过时可建议拆分为 1~3 条开发任务(content + type: BUG/REQUIREMENT/OPTIMIZATION)。',
|
|
'仅输出 JSON,格式:',
|
|
'{"decision":"APPROVE|REJECT","rejectReason":"驳回时必填","note":"通过时附注","reportMarkdown":"markdown摘要","suggestedTasks":[{"content":"...","type":"BUG"}]}',
|
|
].join('\n');
|
|
|
|
@Injectable()
|
|
export class SupportTicketReviewAiService {
|
|
private readonly logger = new Logger(SupportTicketReviewAiService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly llm: LlmChatClient,
|
|
private readonly kb: KnowledgeRetrievalService,
|
|
private readonly devPlan: DevPlanService,
|
|
) {}
|
|
|
|
async preview(ticketIds: string[]): Promise<{ items: BatchReviewPreviewItem[] }> {
|
|
if (!ticketIds.length) throw new BadRequestException('请选择工单');
|
|
const settings = await this.devPlan.getSettingsRaw();
|
|
if (!settings.reviewAssistantLlmConfigId) {
|
|
throw new BadRequestException('请先在开发设置中配置审核 AI 助手的语言模型');
|
|
}
|
|
const llmCfg = await this.prisma.llmApiConfig.findUnique({
|
|
where: { id: settings.reviewAssistantLlmConfigId },
|
|
});
|
|
if (!llmCfg?.enabled) throw new BadRequestException('审核 AI 助手绑定的语言模型未启用');
|
|
|
|
const ids = ticketIds.map(BigInt);
|
|
const tickets = await this.prisma.commonSupportTicket.findMany({
|
|
where: { id: { in: ids }, status: 'PENDING_REVIEW' },
|
|
});
|
|
if (tickets.length !== ids.length) {
|
|
throw new BadRequestException('部分工单不存在或不在待评审状态');
|
|
}
|
|
|
|
const items: BatchReviewPreviewItem[] = [];
|
|
for (const ticket of tickets) {
|
|
let kbBlock = '';
|
|
if (settings.reviewAssistantKnowledgeBaseId) {
|
|
try {
|
|
kbBlock = await this.kb.buildContext(
|
|
settings.reviewAssistantKnowledgeBaseId,
|
|
`${ticket.title}\n${ticket.content ?? ''}`,
|
|
);
|
|
} catch (e) {
|
|
this.logger.warn(`review kb failed: ${String(e)}`);
|
|
}
|
|
}
|
|
|
|
const userPrompt = [
|
|
`工单号:${ticket.ticketNo}`,
|
|
`类型:${ticket.ticketType}`,
|
|
`标题:${ticket.title}`,
|
|
`内容:${ticket.content ?? '(无)'}`,
|
|
kbBlock ? `\n知识库片段:\n${kbBlock}` : '',
|
|
].join('\n');
|
|
|
|
const system = settings.reviewAssistantPrompt?.trim() || DEFAULT_REVIEW_PROMPT;
|
|
const raw = await this.llm.chat({
|
|
baseUrl: llmCfg.baseUrl,
|
|
apiKey: llmCfg.apiKey,
|
|
model: llmCfg.modelName,
|
|
temperature: 0.2,
|
|
maxTokens: 2048,
|
|
messages: [
|
|
{ role: 'system', content: system },
|
|
{ role: 'user', content: userPrompt },
|
|
],
|
|
});
|
|
|
|
const parsed = parseReviewJson(raw, ticket.ticketType);
|
|
items.push({
|
|
ticketId: String(ticket.id),
|
|
ticketNo: ticket.ticketNo,
|
|
title: ticket.title,
|
|
decision: parsed.decision,
|
|
rejectReason: parsed.rejectReason,
|
|
note: parsed.note,
|
|
reportMarkdown: parsed.reportMarkdown,
|
|
suggestedTasks: parsed.suggestedTasks,
|
|
});
|
|
}
|
|
|
|
return { items };
|
|
}
|
|
}
|
|
|
|
function parseReviewJson(
|
|
raw: string,
|
|
ticketType: 'BUG' | 'SUGGESTION' | 'OTHER',
|
|
): {
|
|
decision: 'APPROVE' | 'REJECT';
|
|
rejectReason?: string;
|
|
note?: string;
|
|
reportMarkdown: string;
|
|
suggestedTasks: Array<{ content: string; type: 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION' }>;
|
|
} {
|
|
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
const fallbackType = mapSupportTicketTypeToDevPlanTask(ticketType);
|
|
if (!jsonMatch) {
|
|
return {
|
|
decision: 'APPROVE',
|
|
note: 'AI 未返回结构化结果,请人工确认',
|
|
reportMarkdown: raw.slice(0, 2000),
|
|
suggestedTasks: [{ content: '待人工填写任务内容', type: fallbackType }],
|
|
};
|
|
}
|
|
try {
|
|
const obj = JSON.parse(jsonMatch[0]) as {
|
|
decision?: string;
|
|
rejectReason?: string;
|
|
note?: string;
|
|
reportMarkdown?: string;
|
|
suggestedTasks?: Array<{ content?: string; type?: string }>;
|
|
};
|
|
const decision = obj.decision === 'REJECT' ? 'REJECT' : 'APPROVE';
|
|
const suggestedTasks =
|
|
decision === 'APPROVE'
|
|
? (obj.suggestedTasks ?? [])
|
|
.filter((t) => t.content?.trim())
|
|
.map((t) => ({
|
|
content: t.content!.trim(),
|
|
type: (['BUG', 'REQUIREMENT', 'OPTIMIZATION'].includes(String(t.type))
|
|
? t.type
|
|
: fallbackType) as 'BUG' | 'REQUIREMENT' | 'OPTIMIZATION',
|
|
}))
|
|
: [];
|
|
if (decision === 'APPROVE' && !suggestedTasks.length) {
|
|
suggestedTasks.push({ content: '待人工填写任务内容', type: fallbackType });
|
|
}
|
|
return {
|
|
decision,
|
|
rejectReason: obj.rejectReason?.trim() || undefined,
|
|
note: obj.note?.trim() || undefined,
|
|
reportMarkdown: obj.reportMarkdown?.trim() || raw.slice(0, 2000),
|
|
suggestedTasks,
|
|
};
|
|
} catch {
|
|
return {
|
|
decision: 'APPROVE',
|
|
note: 'AI 返回解析失败,请人工确认',
|
|
reportMarkdown: raw.slice(0, 2000),
|
|
suggestedTasks: [{ content: '待人工填写任务内容', type: fallbackType }],
|
|
};
|
|
}
|
|
}
|