300 lines
10 KiB
PHP
300 lines
10 KiB
PHP
<?php
|
||
/**
|
||
* AI 工具接口 — 编辑辅助:AI 辅助写作风险分析
|
||
*/
|
||
|
||
namespace app\api\controller;
|
||
|
||
use app\common\ArticleParserService;
|
||
use app\common\mq\AiWritingRiskMqPublisher;
|
||
use app\common\PlagiarismService;
|
||
use app\common\service\AiWritingRiskTaskService;
|
||
use think\Validate;
|
||
|
||
class Aitools extends Base
|
||
{
|
||
public function __construct(\think\Request $request = null)
|
||
{
|
||
parent::__construct($request);
|
||
}
|
||
|
||
/**
|
||
* @title 写作风险检测可视化页面(前后端分离入口)
|
||
* @description 仅渲染 HTML;检测与进度走 JSON 接口
|
||
*/
|
||
public function index()
|
||
{
|
||
return $this->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);
|
||
}
|
||
}
|