diff --git a/app/Module/AiTaskSuggestion.php b/app/Module/AiTaskSuggestion.php new file mode 100644 index 000000000..2e37427fd --- /dev/null +++ b/app/Module/AiTaskSuggestion.php @@ -0,0 +1,587 @@ +content ?? ''); + return empty($content) || mb_strlen($content) < 20; + + case ProjectTaskAiEvent::EVENT_SUBTASKS: + // 无子任务且标题长度 > 10 + $hasSubtasks = ProjectTask::where('parent_id', $task->id)->exists(); + return !$hasSubtasks && mb_strlen($task->name) > 10; + + case ProjectTaskAiEvent::EVENT_ASSIGNEE: + // 未指定负责人 + $hasOwner = ProjectTaskUser::where('task_id', $task->id) + ->where('owner', 1) + ->exists(); + return !$hasOwner; + + case ProjectTaskAiEvent::EVENT_SIMILAR: + // 始终执行 + return true; + + default: + return false; + } + } + + /** + * 生成任务描述建议 + */ + public static function generateDescription(ProjectTask $task): ?array + { + $prompt = self::buildDescriptionPrompt($task); + $result = self::callAi($prompt); + + if (empty($result)) { + return null; + } + + return [ + 'type' => 'description', + 'content' => $result, + ]; + } + + /** + * 生成子任务拆分建议 + */ + public static function generateSubtasks(ProjectTask $task): ?array + { + $prompt = self::buildSubtasksPrompt($task); + $result = self::callAi($prompt); + + if (empty($result)) { + return null; + } + + // 解析返回的子任务列表 + $subtasks = self::parseSubtasksList($result); + if (empty($subtasks)) { + return null; + } + + return [ + 'type' => 'subtasks', + 'content' => $subtasks, + ]; + } + + /** + * 生成负责人推荐 + */ + public static function generateAssignee(ProjectTask $task): ?array + { + // 获取项目成员 + $members = self::getProjectMembersInfo($task->project_id); + if (empty($members)) { + return null; + } + + $prompt = self::buildAssigneePrompt($task, $members); + $result = self::callAi($prompt); + + if (empty($result)) { + return null; + } + + // 解析推荐结果 + $recommendations = self::parseAssigneeRecommendations($result, $members); + if (empty($recommendations)) { + return null; + } + + return [ + 'type' => 'assignee', + 'content' => $recommendations, + ]; + } + + /** + * 搜索相似任务 + */ + public static function findSimilarTasks(ProjectTask $task): ?array + { + // 使用 AI 模块的 Embedding 搜索 + $searchText = $task->name . ' ' . ($task->content ?? ''); + + try { + $embedding = AI::getEmbedding($searchText); + if (empty($embedding)) { + return null; + } + + // 搜索相似任务(排除自己和子任务) + $similarTasks = self::searchSimilarByEmbedding( + $embedding, + $task->project_id, + $task->id + ); + + if (empty($similarTasks)) { + return null; + } + + return [ + 'type' => 'similar', + 'content' => $similarTasks, + ]; + } catch (\Exception $e) { + \Log::error('AiTaskSuggestion::findSimilarTasks error: ' . $e->getMessage()); + return null; + } + } + + /** + * 构建描述生成 Prompt + */ + private static function buildDescriptionPrompt(ProjectTask $task): string + { + $projectName = $task->project->name ?? '未知项目'; + + return <<name} +所属项目:{$projectName} + +请按以下格式生成任务描述(使用 Markdown): + +> **背景**:[描述任务的背景和上下文] +> **目标**:[明确任务要达成的目标] +> **验收标准**: +> - [验收标准1] +> - [验收标准2] +> - [验收标准3] + +要求: +1. 内容要专业、简洁 +2. 验收标准要具体、可衡量 +3. 与用户输入语言保持一致 +PROMPT; + } + + /** + * 构建子任务拆分 Prompt + */ + private static function buildSubtasksPrompt(ProjectTask $task): string + { + $content = $task->content ?? ''; + + return <<name} +任务描述:{$content} + +请返回 3-5 个子任务,每行一个,格式如下: +1. [子任务名称] +2. [子任务名称] +... + +要求: +1. 每个子任务要具体、可执行 +2. 子任务之间有合理的顺序 +3. 子任务名称简洁明了(不超过30字) +4. 只返回子任务列表,不要其他内容 +PROMPT; + } + + /** + * 构建负责人推荐 Prompt + */ + private static function buildAssigneePrompt(ProjectTask $task, array $members): string + { + $membersText = ''; + foreach ($members as $member) { + $membersText .= "- {$member['nickname']}(ID:{$member['userid']})"; + if (!empty($member['profession'])) { + $membersText .= ",职位:{$member['profession']}"; + } + $membersText .= ",进行中任务:{$member['in_progress_count']}个"; + $membersText .= ",近期完成:{$member['completed_count']}个"; + if ($member['similar_count'] > 0) { + $membersText .= ",处理过类似任务:{$member['similar_count']}个"; + } + $membersText .= "\n"; + } + + return <<name} +任务描述:{$task->content} + +团队成员: +{$membersText} + +请推荐 2 名最合适的负责人,按优先级排序,格式如下: +1. [userid]|[推荐理由,简短说明] +2. [userid]|[推荐理由,简短说明] + +推荐依据: +1. 优先选择处理过类似任务的成员 +2. 考虑当前工作负载(进行中任务较少的优先) +3. 考虑专业匹配度 + +只返回推荐列表,不要其他内容。 +PROMPT; + } + + /** + * 调用 AI 接口 + */ + private static function callAi(string $prompt): ?string + { + try { + // 使用 AI 模块调用 + $result = AI::invoke([ + ['system', '你是 DooTask 任务管理系统的 AI 助手,帮助用户管理任务。'], + ['user', $prompt], + ]); + + return $result['content'] ?? null; + } catch (\Exception $e) { + \Log::error('AiTaskSuggestion::callAi error: ' . $e->getMessage()); + return null; + } + } + + /** + * 获取项目成员信息 + */ + private static function getProjectMembersInfo(int $projectId): array + { + $projectUsers = ProjectUser::where('project_id', $projectId)->get(); + $members = []; + + foreach ($projectUsers as $pu) { + $user = User::find($pu->userid); + if (!$user || $user->bot || $user->disable_at) { + continue; + } + + // 获取进行中任务数量 + $inProgressCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id') + ->where('project_task_users.userid', $user->userid) + ->whereNull('project_tasks.complete_at') + ->whereNull('project_tasks.archived_at') + ->whereNull('project_tasks.deleted_at') + ->count(); + + // 获取近期完成任务数量 + $completedCount = ProjectTask::join('project_task_users', 'project_tasks.id', '=', 'project_task_users.task_id') + ->where('project_task_users.userid', $user->userid) + ->whereNotNull('project_tasks.complete_at') + ->where('project_tasks.complete_at', '>=', Carbon::now()->subDays(30)) + ->whereNull('project_tasks.deleted_at') + ->count(); + + $members[] = [ + 'userid' => $user->userid, + 'nickname' => $user->nickname, + 'profession' => $user->profession ?? '', + 'in_progress_count' => $inProgressCount, + 'completed_count' => $completedCount, + 'similar_count' => 0, // TODO: 计算相似任务数量 + ]; + } + + return $members; + } + + /** + * 解析子任务列表 + */ + private static function parseSubtasksList(string $text): array + { + $lines = explode("\n", trim($text)); + $subtasks = []; + + foreach ($lines as $line) { + $line = trim($line); + // 移除序号前缀 + $line = preg_replace('/^\d+[\.\)、]\s*/', '', $line); + if (!empty($line) && mb_strlen($line) <= 100) { + $subtasks[] = $line; + } + } + + return array_slice($subtasks, 0, 5); // 最多5个 + } + + /** + * 解析负责人推荐结果 + */ + private static function parseAssigneeRecommendations(string $text, array $members): array + { + $memberMap = []; + foreach ($members as $m) { + $memberMap[$m['userid']] = $m; + } + + $lines = explode("\n", trim($text)); + $recommendations = []; + + foreach ($lines as $line) { + $line = trim($line); + $line = preg_replace('/^\d+[\.\)、]\s*/', '', $line); + + if (preg_match('/^(\d+)\|(.+)$/', $line, $matches)) { + $userid = intval($matches[1]); + $reason = trim($matches[2]); + + if (isset($memberMap[$userid])) { + $recommendations[] = [ + 'userid' => $userid, + 'nickname' => $memberMap[$userid]['nickname'], + 'reason' => $reason, + ]; + } + } + } + + return array_slice($recommendations, 0, 2); // 最多2个 + } + + /** + * 通过 Embedding 搜索相似任务 + */ + private static function searchSimilarByEmbedding(array $embedding, int $projectId, int $excludeTaskId): array + { + // TODO: 实现向量搜索 + // 当前先返回空数组,后续集成 SeekDB 或其他向量搜索 + return []; + } + + /** + * 构建 Markdown 消息 + */ + public static function buildMarkdownMessage(int $taskId, array $suggestions, int $msgId = 0): string + { + $parts = ["## AI 任务建议\n"]; + + foreach ($suggestions as $suggestion) { + switch ($suggestion['type']) { + case 'description': + $parts[] = self::buildDescriptionMarkdown($taskId, $msgId, $suggestion['content']); + break; + case 'subtasks': + $parts[] = self::buildSubtasksMarkdown($taskId, $msgId, $suggestion['content']); + break; + case 'assignee': + $parts[] = self::buildAssigneeMarkdown($taskId, $msgId, $suggestion['content']); + break; + case 'similar': + $parts[] = self::buildSimilarMarkdown($taskId, $msgId, $suggestion['content']); + break; + } + } + + return implode("\n---\n\n", $parts); + } + + /** + * 构建描述建议 Markdown + */ + private static function buildDescriptionMarkdown(int $taskId, int $msgId, string $content): string + { + $applyUrl = "dootask://ai-apply/description/{$taskId}/{$msgId}"; + $dismissUrl = "dootask://ai-dismiss/description/{$taskId}/{$msgId}"; + + return << $name) { + $num = $i + 1; + $list .= "{$num}. {$name}\n"; + } + + $applyUrl = "dootask://ai-apply/subtasks/{$taskId}/{$msgId}"; + $dismissUrl = "dootask://ai-dismiss/subtasks/{$taskId}/{$msgId}"; + + return << $st) { + $num = $i + 1; + $viewUrl = "dootask://task/{$st['id']}"; + $addUrl = "dootask://ai-apply/similar/{$taskId}/{$msgId}?related={$st['id']}"; + $similarity = round($st['similarity'] * 100); + $list .= "{$num}. **#{$st['id']} {$st['name']}** - 相似度 {$similarity}%\n"; + $list .= " [查看任务]({$viewUrl}) [添加关联]({$addUrl})\n\n"; + } + + $dismissUrl = "dootask://ai-dismiss/similar/{$taskId}/{$msgId}"; + + return <<dialog_id)) { + return null; + } + + // 先发送消息获取 msg_id,然后更新消息内容带上 msg_id + $tempMarkdown = self::buildMarkdownMessage($task->id, $suggestions, 0); + + $result = WebSocketDialogMsg::sendMsg( + null, + $task->dialog_id, + 'text', + ['text' => $tempMarkdown, 'type' => 'md'], + self::AI_ASSISTANT_USERID, + true, // push_self + false, // push_retry + true // push_silence + ); + + if (Base::isSuccess($result)) { + $msgId = $result['data']->id ?? 0; + if ($msgId > 0) { + // 更新消息,带上真实的 msg_id + $finalMarkdown = self::buildMarkdownMessage($task->id, $suggestions, $msgId); + WebSocketDialogMsg::sendMsg( + 'change-' . $msgId, + $task->dialog_id, + 'text', + ['text' => $finalMarkdown, 'type' => 'md'], + self::AI_ASSISTANT_USERID + ); + return $msgId; + } + } + + return null; + } + + /** + * 更新消息状态(采纳/忽略后) + */ + public static function updateMessageStatus(int $msgId, int $dialogId, string $type, string $status): void + { + $msg = WebSocketDialogMsg::find($msgId); + if (!$msg) { + return; + } + + $content = $msg->msg['text'] ?? ''; + if (empty($content)) { + return; + } + + // 根据状态替换对应的按钮 + $statusText = $status === 'applied' ? '✓ 已采纳' : '✗ 已忽略'; + + // 替换对应类型的按钮为状态文字 + $pattern = '/\[.*?\]\(dootask:\/\/ai-(apply|dismiss)\/' . preg_quote($type, '/') . '\/\d+\/\d+[^)]*\)\s*/'; + $newContent = preg_replace($pattern, '', $content); + + // 在对应标题后添加状态 + $sectionTitles = [ + 'description' => '### 建议补充任务描述', + 'subtasks' => '### 建议拆分子任务', + 'assignee' => '### 推荐负责人', + 'similar' => '### 发现相似任务', + ]; + + if (isset($sectionTitles[$type])) { + $title = $sectionTitles[$type]; + $newContent = str_replace($title, $title . "\n\n**{$statusText}**", $newContent); + } + + // 更新消息 + WebSocketDialogMsg::sendMsg( + 'change-' . $msgId, + $dialogId, + 'text', + ['text' => $newContent, 'type' => 'md'], + self::AI_ASSISTANT_USERID + ); + } +}