feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
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 }],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user