Files
dukang/server/dukang-api/src/modules/dev-plan/support-ticket-review-ai.service.ts
T
2026-08-04 21:38:49 +08:00

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 }],
};
}
}