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