renderRiskPage(); } /** * @title 开始 AI 辅助写作风险检测(异步入队) * @description 立即返回 task_id;后台 RabbitMQ 消费执行检测 * @param url 稿件链接地址(.docx) * @param p_article_id 可选,业务稿件ID */ public function realtimeWritingRiskDetect() { $data = $this->request->param(); $rule = new Validate([ 'url' => 'require', ]); if (!$rule->check($data)) { return $this->detectJsonError($rule->getError()); } $url = trim((string) $data['url']); $pArticleId = intval($data['p_article_id'] ?? 0); try { $taskService = new AiWritingRiskTaskService(); $created = $taskService->createTask($url, $pArticleId); // 新任务,或复用但仍在排队(可能丢消息)时重新投递 if (empty($created['reused']) || intval($created['status']) === AiWritingRiskTaskService::STATUS_PENDING) { (new AiWritingRiskMqPublisher())->publishTaskStart(intval($created['task_id']), 'enqueue'); } return json([ 'code' => 200, 'msg' => !empty($created['reused']) ? '已有进行中的检测任务' : '已开始检测', 'data' => [ 'task_id' => $created['task_id'], 'task_no' => $created['task_no'], 'status' => $created['status'], 'status_text' => intval($created['status']) === 1 ? 'running' : 'queued', 'progress_percent' => 0, 'message' => $created['message'], 'reused' => !empty($created['reused']), ], ]); } catch (\Exception $e) { return $this->detectJsonError($e->getMessage()); } } /** * @title 刷新 AI 辅助写作风险检测进度 * @description * - 浏览器 GET(无 format=json)→ 可视化页面:未完成显示进度,完成后显示结果 * - POST 或 format=json → JSON(供前端轮询) * @param task_id 任务ID(与 task_no 二选一) * @param task_no 任务号(与 task_id 二选一) */ public function realtimeWritingRiskProgress() { $data = $this->request->param(); $taskId = intval($data['task_id'] ?? 0); $taskNo = trim((string) ($data['task_no'] ?? '')); if ($taskId <= 0 && $taskNo === '') { if ($this->wantsProgressJson()) { return $this->detectJsonError('task_id 或 task_no 必填'); } return $this->renderRiskPage(); } try { $progress = (new AiWritingRiskTaskService())->getProgress($taskNo, $taskId); } catch (\Exception $e) { if ($this->wantsProgressJson()) { return $this->detectJsonError($e->getMessage()); } return $this->renderRiskPage([ 'page_error' => $e->getMessage(), 'task_id' => $taskId, 'task_no' => $taskNo, ]); } if ($this->wantsProgressJson()) { return json([ 'code' => 200, 'msg' => 'success', 'data' => $progress, ]); } return $this->renderRiskPage([ 'task_id' => intval($progress['task_id'] ?? $taskId), 'task_no' => (string) ($progress['task_no'] ?? $taskNo), 'initial_data' => $progress, ]); } /** * 是否返回 JSON:POST、format=json、XHR/fetch 显式要 JSON */ private function wantsProgressJson(): bool { if (strtolower((string) $this->request->param('format', '')) === 'json') { return true; } if ($this->request->isAjax()) { return true; } $accept = strtolower((string) $this->request->header('accept', '')); if (strpos($accept, 'application/json') !== false) { return true; } // 页面内 fetch 使用 POST if ($this->request->isPost()) { return true; } return false; } /** * 渲染可视化页 * @param array $opts task_id/task_no/initial_data/page_error */ private function renderRiskPage(array $opts = []) { $apiStart = (string) url('api/aitools/realtimeWritingRiskDetect'); $apiProgress = (string) url('api/aitools/realtimeWritingRiskProgress'); $initial = isset($opts['initial_data']) && is_array($opts['initial_data']) ? $opts['initial_data'] : null; $this->assign([ 'api_start_js' => json_encode($apiStart, JSON_UNESCAPED_SLASHES), 'api_progress_js' => json_encode($apiProgress, JSON_UNESCAPED_SLASHES), 'init_task_id_js' => json_encode(intval($opts['task_id'] ?? 0)), 'init_task_no_js' => json_encode((string) ($opts['task_no'] ?? ''), JSON_UNESCAPED_UNICODE), 'init_data_js' => json_encode($initial, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'page_error_js' => json_encode((string) ($opts['page_error'] ?? ''), JSON_UNESCAPED_UNICODE), ]); return $this->fetch('aiTools/index'); } /** * @title AI 辅助写作风险分析(编辑辅助) * @description 解析稿件结构,并从模版句、重复短语、句长特征等维度输出风险报告 * @param url 稿件链接地址(.docx) */ public function writingRiskAnalysis() { $data = $this->request->param(); $rule = new Validate([ 'url' => 'require', ]); if (!$rule->check($data)) { return jsonError($rule->getError()); } try { $filePath = $this->resolveManuscriptUrl($data['url']); } catch (\Exception $e) { return jsonError($e->getMessage()); } $result = ArticleParserService::analyzeWritingRisk($filePath); if (empty($result['status']) || intval($result['status']) !== 1) { return jsonError(empty($result['msg']) ? '稿件分析失败' : $result['msg']); } return jsonSuccess([ 'manuscript' => $this->pickManuscriptFields($result['data']), 'risk_analysis' => $result['data']['risk_analysis'] ?? [], 'metrics' => $this->pickMetricFields($result['data']), ]); } /** * @title 根据稿件链接解析稿件结构 * @description 从稿件地址解析标题、摘要、关键词及 IMRaD 正文分节(不含风险综述) * @param url 稿件链接地址(.docx) */ public function parseManuscript() { $data = $this->request->param(); $rule = new Validate([ 'url' => 'require', ]); if (!$rule->check($data)) { return jsonError($rule->getError()); } try { $filePath = $this->resolveManuscriptUrl($data['url']); } catch (\Exception $e) { return jsonError($e->getMessage()); } $result = ArticleParserService::parseManuscriptStructure($filePath); if (empty($result['status']) || intval($result['status']) !== 1) { return jsonError(empty($result['msg']) ? '稿件解析失败' : $result['msg']); } return jsonSuccess($result['data']); } private function detectJsonError($msg) { return json(['code' => 500, 'msg' => (string) $msg, 'data' => []]); } private function pickManuscriptFields(array $data): array { $keys = ['title', 'abstract', 'keywords', 'Introduction', 'Methods', 'Results', 'Discussion', 'Conclusion', 'References']; $out = []; foreach ($keys as $key) { if (array_key_exists($key, $data)) { $out[$key] = $data[$key]; } } return $out; } private function pickMetricFields(array $data): array { $keys = [ 'sentence_stats', 'sentence_stats_by_section', 'repeated_phrases', 'repeated_phrases_by_section', 'template_sentence_stats', ]; $out = []; foreach ($keys as $key) { if (array_key_exists($key, $data)) { $out[$key] = $data[$key]; } } return $out; } /** * 将稿件链接解析为本地可读文件路径 */ private function resolveManuscriptUrl($url) { $url = trim((string)$url); if ($url === '') { throw new \Exception('稿件地址不能为空'); } if (preg_match('/^([a-zA-Z]:[\\\\\/]|\/)/', $url) && is_file($url)) { return $url; } if (preg_match('#^https?://#i', $url)) { try { return (new PlagiarismService())->resolveFileUrlToLocal($url); } catch (\Exception $e) { $path = parse_url($url, PHP_URL_PATH); if ($path) { $local = rtrim(ROOT_PATH, '/') . $path; if (is_file($local)) { return $local; } } throw $e; } } $local = rtrim(ROOT_PATH, '/') . '/public/' . ltrim(ltrim($url, '/'), 'public'); if (is_file($local)) { return $local; } throw new \Exception('无法解析稿件文件路径: ' . $url); } }