参考文献作者堆叠

参考文献相关性检测
作者ai写作辅助检测工作
This commit is contained in:
wyn
2026-07-15 10:49:05 +08:00
parent da71dfc04e
commit 8785610e6d
27 changed files with 10008 additions and 204 deletions

View File

@@ -0,0 +1,299 @@
<?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,
]);
}
/**
* 是否返回 JSONPOST、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);
}
}