dootask/app/Module/AiTaskSuggestion.php
kuaifan 29be29b9cf feat(ai): 优化 AI 提示词并完善建议交互功能
- 优化后端提示词:描述生成、子任务拆分、负责人推荐,新增栏目信息,去掉无效的 similar_count
- 优化前端提示词:去掉硬性字数限制,即时消息改为简短输出
- 新增 :::ai-action{...}::: 语法处理,支持单独采纳/忽略 assignee 和 similar
- 采纳/忽略后更新消息状态显示
- 负责人改为追加模式,保留现有负责人
- 新增任务关联功能,similar 采纳时自动创建双向关联
- 相似度阈值从 0.7 调整为 0.5,搜索结果增加到 200

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-01-21 15:30:07 +00:00

714 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Module;
use App\Models\ProjectTask;
use App\Models\ProjectTaskAiEvent;
use App\Models\ProjectTaskUser;
use App\Models\ProjectUser;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Manticore\ManticoreBase;
use Cache;
use Carbon\Carbon;
class AiTaskSuggestion
{
/**
* AI 助手的 userid
*/
const AI_ASSISTANT_USERID = -1;
/**
* 相似度阈值
*/
const SIMILAR_THRESHOLD = 0.7;
/**
* 检查是否满足执行条件
*/
public static function shouldExecute(ProjectTask $task, string $eventType): bool
{
switch ($eventType) {
case ProjectTaskAiEvent::EVENT_DESCRIPTION:
// 描述为空或长度 < 20
$content = trim($task->content ?? '');
return empty($content) || mb_strlen($content) < 20;
case ProjectTaskAiEvent::EVENT_SUBTASKS:
// 无子任务且标题长度 > 5
$hasSubtasks = ProjectTask::where('parent_id', $task->id)->exists();
return !$hasSubtasks && mb_strlen($task->name) > 5;
case ProjectTaskAiEvent::EVENT_ASSIGNEE:
// 未指定负责人
$hasOwner = ProjectTaskUser::where('task_id', $task->id)->where('owner', 1)->exists();
return !$hasOwner;
case ProjectTaskAiEvent::EVENT_SIMILAR:
// 需要安装 search 插件才能使用向量搜索
return Apps::isInstalled('search');
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
{
// 获取当前任务已有的成员(负责人和协助人)
$existingUserIds = ProjectTaskUser::where('task_id', $task->id)
->pluck('userid')
->toArray();
// 获取项目成员,排除已有任务成员
$members = self::getProjectMembersInfo($task->project_id);
$members = array_filter($members, function ($member) use ($existingUserIds) {
return !in_array($member['userid'], $existingUserIds);
});
$members = array_values($members); // 重新索引
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;
if (empty($searchText)) {
return null;
}
try {
$result = AI::getEmbedding($searchText);
if (Base::isError($result) || empty($result['data'])) {
return null;
}
$embedding = $result['data'];
// 搜索相似任务(排除自己和子任务)
$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 escapeUserInput(string $input, int $length = 500): string
{
// 移除可能影响 AI Prompt 解析的特殊字符
$input = str_replace(['```', '---', '==='], '', $input);
// 截断过长的输入
return mb_substr(trim($input), 0, $length);
}
/**
* 构建描述生成 Prompt
*/
private static function buildDescriptionPrompt(ProjectTask $task): string
{
$taskName = self::escapeUserInput($task->name, 100);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
return <<<PROMPT
你是一名任务规划助手,擅长根据任务标题推断并补充任务描述。
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
你的任务:
根据标题、项目和栏目信息,推断任务意图并生成实用的任务描述。
生成原则:
1. 基于标题关键词和上下文进行合理推断,内容要具体、可执行
2. 使用 Markdown 格式,根据任务性质灵活组织结构(可包含目标、要求、验收标准等)
3. 简单任务保持简洁,复杂任务可适当展开,避免空泛的套话
4. 与标题语言保持一致
输出要求:
- 仅返回 Markdown 格式的描述内容
- 禁止输出额外说明、引导语或与任务无关的内容
PROMPT;
}
/**
* 构建子任务拆分 Prompt
*/
private static function buildSubtasksPrompt(ProjectTask $task): string
{
$taskName = self::escapeUserInput($task->name, 100);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
$content = self::escapeUserInput($task->content ?? '');
return <<<PROMPT
你是一名任务拆解助手,擅长将复杂任务分解为可执行的子任务。
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
任务描述:{$content}
你的任务:
分析任务内容,拆解出关键的执行步骤作为子任务。
拆解原则:
1. 每个子任务聚焦单一可执行动作,避免含糊或重复
2. 根据任务复杂度灵活决定数量(通常 2-5 个),简单任务少拆,复杂任务多拆
3. 子任务之间保持合理的执行顺序或逻辑关系
4. 子任务名称简洁明了,控制在 8-30 个字符内
5. 与任务标题语言保持一致
输出格式:
1. [子任务名称]
2. [子任务名称]
...
输出要求:
- 仅返回子任务列表,禁止输出额外说明或引导语
PROMPT;
}
/**
* 构建负责人推荐 Prompt
*/
private static function buildAssigneePrompt(ProjectTask $task, array $members): string
{
$taskName = self::escapeUserInput($task->name, 100);
$projectName = self::escapeUserInput($task->project->name ?? '未知项目', 100);
$columnName = self::escapeUserInput($task->projectColumn->name ?? '未知栏目', 50);
$taskContent = self::escapeUserInput($task->content ?? '');
$membersText = '';
foreach ($members as $member) {
$nickname = self::escapeUserInput($member['nickname'], 20);
$membersText .= "- {$nickname}ID:{$member['userid']}";
if (!empty($member['profession'])) {
$profession = self::escapeUserInput($member['profession'], 50);
$membersText .= ",职位:{$profession}";
}
$membersText .= ",进行中:{$member['in_progress_count']}";
$membersText .= ",近期完成:{$member['completed_count']}";
$membersText .= "\n";
}
return <<<PROMPT
你是一名任务分配助手,根据任务内容和成员情况推荐合适的负责人。
所属项目:{$projectName}
所属栏目:{$columnName}
任务标题:{$taskName}
任务描述:{$taskContent}
可选成员:
{$membersText}
推荐原则:
1. 分析任务内容,匹配成员职位或专业方向
2. 优先推荐进行中任务较少的成员,平衡工作负载
3. 近期完成任务多说明执行力强,可作为参考
输出格式:
1. [userid]|[推荐理由]
2. [userid]|[推荐理由]
输出要求:
- 推荐 1-2 名最合适的负责人,按优先级排序
- 推荐理由需具体说明为何此人适合该任务,不超过 20 字
- 仅返回推荐列表,禁止输出额外说明
PROMPT;
}
/**
* 调用 AI 接口
*/
private static function callAi(string $prompt): ?string
{
try {
// 使用 AI 模块调用
$result = AI::invoke([
['system', '你是 DooTask 任务管理系统的 AI 助手,帮助用户管理任务。'],
['user', $prompt],
]);
if (Base::isError($result)) {
\Log::error('AiTaskSuggestion::callAi error: ' . ($result['msg'] ?? 'Unknown error'));
return null;
}
return $result['data']['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 = [];
$addedUserIds = []; // 记录已添加的用户ID防止重复
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 (in_array($userid, $addedUserIds)) {
continue;
}
if (isset($memberMap[$userid])) {
$recommendations[] = [
'userid' => $userid,
'nickname' => $memberMap[$userid]['nickname'],
'reason' => $reason,
];
$addedUserIds[] = $userid;
}
}
}
return array_slice($recommendations, 0, 2); // 最多2个
}
/**
* 通过 Embedding 搜索相似任务
*
* @param array $embedding 任务内容的向量表示
* @param int $projectId 项目ID用于过滤同项目任务
* @param int $excludeTaskId 排除的任务ID当前任务
* @return array 相似任务列表
*/
private static function searchSimilarByEmbedding(array $embedding, int $projectId, int $excludeTaskId): array
{
if (empty($embedding)) {
return [];
}
try {
// 使用 ManticoreBase 进行向量搜索
// userid=0 跳过权限过滤,我们通过 project_id 过滤
$results = ManticoreBase::taskVectorSearch($embedding, 0, 200);
if (empty($results)) {
return [];
}
// 获取当前任务的子任务ID列表
$childTaskIds = ProjectTask::where('parent_id', $excludeTaskId)
->whereNull('deleted_at')
->pluck('id')
->toArray();
// 过滤:同项目、排除当前任务及其子任务、相似度阈值
$similarTasks = [];
foreach ($results as $item) {
// 过滤不同项目的任务
if ($item['project_id'] != $projectId) {
continue;
}
// 排除当前任务
if ($item['task_id'] == $excludeTaskId) {
continue;
}
// 排除子任务
if (in_array($item['task_id'], $childTaskIds)) {
continue;
}
// 相似度阈值0.5 以上才算相似)
$similarity = $item['similarity'] ?? 0;
if ($similarity < 0.5) {
continue;
}
$similarTasks[] = [
'task_id' => $item['task_id'],
'name' => $item['task_name'] ?? '',
'similarity' => round($similarity, 2),
];
// 最多返回 5 个相似任务
if (count($similarTasks) >= 5) {
break;
}
}
return $similarTasks;
} catch (\Exception $e) {
\Log::error('searchSimilarByEmbedding error: ' . $e->getMessage());
return [];
}
}
/**
* 构建 Markdown 消息
*/
public static function buildMarkdownMessage(int $taskId, array $suggestions, int $msgId = 0): string
{
$parts = [];
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\n", $parts);
}
/**
* 构建描述建议 Markdown
*/
private static function buildDescriptionMarkdown(int $taskId, int $msgId, string $content): string
{
return <<<MD
### 建议补充任务描述
{$content}
:::ai-action{type="description" task="{$taskId}" msg="{$msgId}"}:::
MD;
}
/**
* 构建子任务建议 Markdown
*/
private static function buildSubtasksMarkdown(int $taskId, int $msgId, array $subtasks): string
{
$list = '';
foreach ($subtasks as $i => $name) {
$num = $i + 1;
$list .= "{$num}. {$name}\n";
}
return <<<MD
### 建议拆分子任务
{$list}
:::ai-action{type="subtasks" task="{$taskId}" msg="{$msgId}"}:::
MD;
}
/**
* 构建负责人建议 Markdown
*/
private static function buildAssigneeMarkdown(int $taskId, int $msgId, array $recommendations): string
{
$list = '';
foreach ($recommendations as $rec) {
$stUserId = $rec['userid'];
$viewUrl = "dootask://contact/{$stUserId}";
$list .= "- **[{$rec['nickname']}]({$viewUrl})** - {$rec['reason']} :::ai-action{type=\"assignee\" task=\"{$taskId}\" msg=\"{$msgId}\" userid=\"{$stUserId}\"}:::\n";
}
return <<<MD
### 推荐负责人
{$list}
MD;
}
/**
* 构建相似任务 Markdown
*/
private static function buildSimilarMarkdown(int $taskId, int $msgId, array $similarTasks): string
{
$list = '';
foreach ($similarTasks as $i => $st) {
$num = $i + 1;
$stTaskId = $st['task_id'];
$viewUrl = "dootask://task/{$stTaskId}";
$list .= "{$num}. **[#{$stTaskId}]({$viewUrl})** {$st['name']} :::ai-action{type=\"similar\" task=\"{$taskId}\" msg=\"{$msgId}\" related=\"{$stTaskId}\"}:::\n";
}
return <<<MD
### 发现相似任务
以下任务与当前任务内容相似,可能是重复任务或可作为参考:
{$list}
MD;
}
/**
* 发送建议消息
*/
public static function sendSuggestionMessage(ProjectTask $task, array $suggestions): ?int
{
if (empty($suggestions)) {
return null;
}
// 如果任务没有对话,自动创建
if (!$task->dialog_id) {
$dialog = WebSocketDialog::createGroup($task->name, $task->relationUserids(), 'task');
if ($dialog) {
$task->dialog_id = $dialog->id;
$task->save();
$task->pushMsg('dialog');
} else {
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::isError($result)) {
return null;
}
$msgId = $result['data']->id ?? 0;
if (empty($msgId)) {
return null;
}
// 更新消息,带上真实的 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,
true, // push_self
);
return $msgId;
}
/**
* 更新消息状态(采纳/忽略后)
*
* @param int $msgId 消息ID
* @param int $dialogId 对话ID
* @param string $type 建议类型
* @param string $status 状态applied/dismissed
* @param int $userid 用户IDassignee类型单独处理时使用
* @param int $related 关联任务IDsimilar类型单独处理时使用
* @return array 更新后的消息数据
*/
public static function updateMessageStatus(int $msgId, int $dialogId, string $type, string $status, int $userid = 0, int $related = 0): array
{
// 验证消息存在且属于指定对话
$msg = WebSocketDialogMsg::where('id', $msgId)
->where('dialog_id', $dialogId)
->first();
if (!$msg) {
return Base::retError('消息不存在');
}
$content = $msg->msg['text'] ?? '';
if (empty($content)) {
return Base::retError('消息内容为空');
}
// 根据类型和参数构建匹配模式,添加 status 属性
if ($type === 'assignee' && $userid > 0) {
$pattern = '/(:::ai-action\{type="assignee"[^}]*userid="' . $userid . '"[^}]*)\}:::/';
} elseif ($type === 'similar' && $related > 0) {
$pattern = '/(:::ai-action\{type="similar"[^}]*related="' . $related . '"[^}]*)\}:::/';
} else {
$pattern = '/(:::ai-action\{type="' . preg_quote($type, '/') . '"[^}]*)\}:::/';
}
$newContent = preg_replace($pattern, '$1 status="' . $status . '"}:::', $content);
// 更新消息并返回结果
return WebSocketDialogMsg::sendMsg(
'change-' . $msgId,
$dialogId,
'text',
['text' => $newContent, 'type' => 'md'],
self::AI_ASSISTANT_USERID
);
}
}