From 8785610e6dd42122b943b4e4843af50bc0dbe67f Mon Sep 17 00:00:00 2001 From: wyn <1074145239@qq.com> Date: Wed, 15 Jul 2026 10:49:05 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=8F=82=E8=80=83=E6=96=87=E7=8C=AE?= =?UTF-8?q?=E4=BD=9C=E8=80=85=E5=A0=86=E5=8F=A0=20=E5=8F=82=E8=80=83?= =?UTF-8?q?=E6=96=87=E7=8C=AE=E7=9B=B8=E5=85=B3=E6=80=A7=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=20=E4=BD=9C=E8=80=85ai=E5=86=99=E4=BD=9C=E8=BE=85=E5=8A=A9?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=B7=A5=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/api/controller/AITools.php | 299 ++ application/api/controller/Base.php | 8 + application/api/controller/Preaccept.php | 92 + application/api/controller/References.php | 116 +- application/api/view/aiTools/index.html | 849 ++++++ application/command.php | 1 + .../command/ReferenceCheckMqConsume.php | 6 +- application/common/ArticleParserService.php | 853 +++++- application/common/BackgroundCheckService.php | 98 +- .../common/CmaJournalLiteratureService.php | 329 +++ application/common/DbReconnectHelper.php | 55 + application/common/EuropePmcService.php | 120 + application/common/Journal.php | 512 ++++ application/common/ProductionArticleRefer.php | 15 + ...roductionArticleReferLiteratureService.php | 171 ++ .../common/ReferenceAuthorIdentityService.php | 283 ++ application/common/ReferenceCheckService.php | 78 +- .../ReferenceLiteratureFetchService.php | 622 +++++ .../common/ReferenceReferAuthorService.php | 520 ++++ .../common/ReferenceRelevanceCheckService.php | 2415 +++++++++++++++++ .../common/ReferenceStackingStatsService.php | 834 ++++++ application/common/UnpaywallService.php | 76 + .../common/UserInfoFromFileService.php | 358 +++ application/common/mq/RabbitMqConfig.php | 6 + .../common/mq/ReferenceCheckArticleWorker.php | 19 +- .../service/ReferenceRelevanceLlmService.php | 1470 ++++++++-- application/extra/rabbitmq.php | 7 + 27 files changed, 10008 insertions(+), 204 deletions(-) create mode 100644 application/api/controller/AITools.php create mode 100644 application/api/view/aiTools/index.html create mode 100644 application/common/CmaJournalLiteratureService.php create mode 100644 application/common/DbReconnectHelper.php create mode 100644 application/common/EuropePmcService.php create mode 100644 application/common/Journal.php create mode 100644 application/common/ProductionArticleReferLiteratureService.php create mode 100644 application/common/ReferenceAuthorIdentityService.php create mode 100644 application/common/ReferenceLiteratureFetchService.php create mode 100644 application/common/ReferenceReferAuthorService.php create mode 100644 application/common/ReferenceRelevanceCheckService.php create mode 100644 application/common/ReferenceStackingStatsService.php create mode 100644 application/common/UnpaywallService.php create mode 100644 application/common/UserInfoFromFileService.php diff --git a/application/api/controller/AITools.php b/application/api/controller/AITools.php new file mode 100644 index 00000000..b74033b4 --- /dev/null +++ b/application/api/controller/AITools.php @@ -0,0 +1,299 @@ +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); + } +} diff --git a/application/api/controller/Base.php b/application/api/controller/Base.php index 6d76cb01..0dcf43ce 100644 --- a/application/api/controller/Base.php +++ b/application/api/controller/Base.php @@ -272,6 +272,14 @@ class Base extends Controller $this->production_article_refer_obj->where('p_article_id', $refer_info['p_article_id'])->where('index', ">", $refer_info['index'])->where('state', 0)->setDec('index'); $this->production_article_refer_obj->where('p_refer_id', $p_refer_id)->update(['state' => 1]); + try { + Db::name('production_article_refer_author') + ->where('p_refer_id', intval($p_refer_id)) + ->delete(); + } catch (\Exception $e) { + \think\Log::error('delOneRefer delete refer_author p_refer_id=' . $p_refer_id . ' ' . $e->getMessage()); + } + // 文献集合已变更,原校对结果的 reference_no 已全部错位,整篇标记为未校对 try { (new \app\common\ReferenceCheckService()) diff --git a/application/api/controller/Preaccept.php b/application/api/controller/Preaccept.php index 0ec24534..83653445 100644 --- a/application/api/controller/Preaccept.php +++ b/application/api/controller/Preaccept.php @@ -7,6 +7,8 @@ use think\Env; use think\Queue; use think\Validate; use app\common\CrossrefService; +use app\common\ReferenceCheckService; +use app\common\ReferenceReferAuthorService; use app\common\ReferenceRelevanceCheckService; class Preaccept extends Base @@ -36,6 +38,78 @@ class Preaccept extends Base } } + /** + * 新增参考文献后同步作者明细到 t_production_article_refer_author + */ + private function syncReferAuthorsAfterInsert($pReferId, $pArticleId, array $refer) + { + $pReferId = intval($pReferId); + $pArticleId = intval($pArticleId); + if ($pReferId <= 0 || $pArticleId <= 0) { + return; + } + try { + (new ReferenceReferAuthorService())->syncOneRefer($pReferId, $pArticleId, $refer); + } catch (\Exception $e) { + \think\Log::error( + 'syncReferAuthorsAfterInsert p_refer_id=' . $pReferId + . ' p_article_id=' . $pArticleId . ' ' . $e->getMessage() + ); + } + } + + /** + * 编辑参考文献后同步作者明细;refer_doi 变化时先删后重新抓取入库 + */ + private function syncReferAuthorsAfterUpdate($pReferId, $pArticleId, array $oldRefer, array $updata) + { + $pReferId = intval($pReferId); + $pArticleId = intval($pArticleId); + if ($pReferId <= 0 || $pArticleId <= 0 || empty($oldRefer)) { + return; + } + + $newRefer = array_merge($oldRefer, $updata); + $refUtil = new ReferenceCheckService(); + $oldDoi = $this->normalizeReferDoi($refUtil->extractDoiFromRefer($oldRefer)); + $newDoi = $this->normalizeReferDoi($refUtil->extractDoiFromRefer($newRefer)); + $doiChanged = $oldDoi !== $newDoi; + + $authorChanged = false; + if (array_key_exists('author', $updata)) { + $authorChanged = trim((string)($oldRefer['author'] ?? '')) !== trim((string)$updata['author']); + } + + if (!$doiChanged && !$authorChanged) { + return; + } + + try { + $svc = new ReferenceReferAuthorService(); + if ($doiChanged) { + Db::name('production_article_refer_author')->where('p_refer_id', $pReferId)->delete(); + $svc->syncOneRefer($pReferId, $pArticleId, $newRefer); + } elseif ($authorChanged) { + $svc->syncFromReferAuthorField( + $pReferId, + $pArticleId, + (string)($newRefer['author'] ?? '') + ); + } + } catch (\Exception $e) { + \think\Log::error( + 'syncReferAuthorsAfterUpdate p_refer_id=' . $pReferId + . ' p_article_id=' . $pArticleId . ' ' . $e->getMessage() + ); + } + } + + private function normalizeReferDoi($doi) + { + $doi = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', trim((string)$doi)); + return strtolower(trim($doi, " \t\n\r\0\x0B/")); + } + /**获取文章参考文献列表 * @return \think\response\Json @@ -165,6 +239,11 @@ class Preaccept extends Base $adId= $this->production_article_refer_obj->insertGetId($insert); $this->production_article_refer_obj->where('p_article_id', $p_info['p_article_id'])->where("p_refer_id", "<>", $adId)->where("index", ">", $pre_refer['index'])->where('state', 0)->setInc('index'); $this->resetArticleChecksOnReferChange(intval($p_info['p_article_id']), 'addRefer'); + $this->syncReferAuthorsAfterInsert( + $adId, + intval($p_info['p_article_id']), + array_merge($insert, ['p_refer_id' => $adId, 'p_article_id' => $p_info['p_article_id']]) + ); return jsonSuccess([]); @@ -222,6 +301,12 @@ class Preaccept extends Base $adId= $this->production_article_refer_obj->insertGetId($insert); $this->production_article_refer_obj->where('p_article_id', $p_info['p_article_id'])->where("p_refer_id", "<>", $adId)->where("index", ">", $pre_refer['index'])->where('state', 0)->setInc('index'); $this->resetArticleChecksOnReferChange(intval($p_info['p_article_id']), 'addReferByParticleid'); + $this->syncReferAuthorsAfterInsert( + $adId, + intval($p_info['p_article_id']), + array_merge($insert, ['p_refer_id' => $adId, 'p_article_id' => $p_info['p_article_id']]) + ); + return jsonSuccess([]); } @@ -498,6 +583,13 @@ class Preaccept extends Base \think\Log::error('editRefer enqueueRecheckByPReferId p_refer_id=' . $data['p_refer_id'] . ' ' . $e->getMessage()); } + $this->syncReferAuthorsAfterUpdate( + intval($data['p_refer_id']), + intval($old_refer_info['p_article_id']), + $old_refer_info, + $updata + ); + return jsonSuccess([]); } diff --git a/application/api/controller/References.php b/application/api/controller/References.php index 3408dc06..57a4ee5d 100644 --- a/application/api/controller/References.php +++ b/application/api/controller/References.php @@ -13,6 +13,8 @@ use think\Env; use think\Queue; use app\common\ReferenceCheckService; use app\common\ReferenceRelevanceCheckService; +use app\common\ReferenceStackingStatsService; +use app\common\ReferenceReferAuthorService; use app\common\DbReconnectHelper; /** * @title 参考文献 @@ -1818,5 +1820,117 @@ class References extends Base return $frag; } - + /** + * 参考文献引用堆叠统计(同作者>15%、同刊>20%、自引>10%,实时计算) + * + * POST/GET: p_article_id(必填) + */ + public function referenceStackingStats() + { + $aParam = $this->request->post(); + if (empty($aParam)) { + $aParam = $this->request->param(); + } + + $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); + if ($iPArticleId <= 0) { + return json_encode(array('status' => 2, 'msg' => 'Please select an article')); + } + + try { + $svc = new ReferenceStackingStatsService(); + $result = $svc->getThresholdStackingByPArticleId($iPArticleId); + return jsonSuccess($result); + } catch (\Exception $e) { + return jsonError($e->getMessage()); + } + } + + /** + * 测试:同步参考文献作者明细到 t_production_article_refer_author + * POST/GET: p_article_id(必填), p_refer_id(可选,仅同步单条), include_authors(可选,默认 0;1 返回作者明细), sleep_ms(可选,默认 120) + */ + public function referenceReferAuthorSyncTest() + { + $aParam = $this->request->post(); + if (empty($aParam)) { + $aParam = $this->request->param(); + } + + $iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']); + if ($iPArticleId <= 0) { + return json_encode(array('status' => 2, 'msg' => 'Please select an article')); + } + + $iPReferId = empty($aParam['p_refer_id']) ? 0 : intval($aParam['p_refer_id']); + $includeAuthors = !empty($aParam['include_authors']) && intval($aParam['include_authors']) === 1; + $sleepMs = isset($aParam['sleep_ms']) ? max(0, intval($aParam['sleep_ms'])) : 120; + + try { + $svc = new ReferenceReferAuthorService(); + + if ($iPReferId > 0) { + $refer = Db::name('production_article_refer') + ->where('p_refer_id', $iPReferId) + ->where('p_article_id', $iPArticleId) + ->where('state', 0) + ->find(); + if (empty($refer)) { + return jsonError('Reference not found'); + } + + $refUtil = new ReferenceCheckService(); + $doi = $refUtil->extractDoiFromRefer($refer); + $count = 0; + if ($doi !== '') { + $crossref = new CrossrefService([ + 'mailto' => trim((string)Env::get('crossref_mailto', '')), + ]); + $summary = $crossref->fetchWorkSummary($doi); + if ($summary === null || empty($summary['doi'])) { + $summary = ['doi' => $doi, 'raw' => []]; + } + $count = $svc->syncFromWorkSummary($iPReferId, $iPArticleId, $doi, $summary); + } + if ($count <= 0) { + $count = $svc->syncFromReferAuthorField($iPReferId, $iPArticleId, (string)($refer['author'] ?? '')); + } + + $result = [ + 'p_article_id' => $iPArticleId, + 'p_refer_id' => $iPReferId, + 'reference_no' => intval($refer['index']) + 1, + 'doi' => $doi, + 'authors' => $count, + 'synced' => $count > 0 ? 1 : 0, + 'failed' => $count > 0 ? 0 : 1, + ]; + } else { + $result = $svc->syncByPArticleId($iPArticleId, ['sleep_ms' => $sleepMs]); + } + + if ($includeAuthors) { + $query = Db::name('production_article_refer_author') + ->alias('a') + ->join('production_article_refer r', 'r.p_refer_id = a.p_refer_id', 'LEFT') + ->field('a.p_refer_id,r.index,a.author_seq,a.display_name,a.citation_name,a.orcid,a.openalex_id,a.identity_source') + ->where('a.p_article_id', $iPArticleId); + if ($iPReferId > 0) { + $query->where('a.p_refer_id', $iPReferId); + } + $rows = $query->order('r.index asc, a.author_seq asc')->select(); + foreach ($rows as &$row) { + $row['reference_no'] = intval($row['index']) + 1; + unset($row['index']); + } + unset($row); + $result['author_rows'] = $rows; + } + + return jsonSuccess($result); + } catch (\Exception $e) { + return jsonError($e->getMessage()); + } + } + } diff --git a/application/api/view/aiTools/index.html b/application/api/view/aiTools/index.html new file mode 100644 index 00000000..8f4b7adf --- /dev/null +++ b/application/api/view/aiTools/index.html @@ -0,0 +1,849 @@ + + + + + + AI 辅助写作风险检测 + + + + + + +
+
+

AI 辅助写作风险检测

+

面向编辑的机器化写作风险辅助工具:模板化表达、机械化语言、空泛描述、章节逻辑与重复表达。不是 AI 生成百分比判断。

+
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + + +
+
+ 排队中 + 0% +
+
+
current_step: -
+
+ +
+
+ +
+
+
+
+ + + + +
+
0
+
-
+
+
+
+
+
+ +

分维度风险

+
+ +

章节评分

+
+ +

编辑建议

+
+ + +
+ +

重点问题

+
+ +

全部明细

+
+
+ + +
+ + + + diff --git a/application/command.php b/application/command.php index 43892e98..7be87dc3 100644 --- a/application/command.php +++ b/application/command.php @@ -11,4 +11,5 @@ return [ 'app\\command\\ReferenceCheckMqConsume', + 'app\\command\\AiWritingRiskMqConsume', ]; diff --git a/application/command/ReferenceCheckMqConsume.php b/application/command/ReferenceCheckMqConsume.php index 6d0103e5..853f781e 100644 --- a/application/command/ReferenceCheckMqConsume.php +++ b/application/command/ReferenceCheckMqConsume.php @@ -55,10 +55,14 @@ class ReferenceCheckMqConsume extends Command $msg->ack(); return; } + $pArticleId = intval(isset($payload['p_article_id']) ? $payload['p_article_id'] : 0); + $batchId = intval(isset($payload['batch_id']) ? $payload['batch_id'] : 0); + $output->writeln('[' . date('H:i:s') . '] consume p_article_id=' . $pArticleId . ' batch_id=' . $batchId); try { $worker->handleMessage($payload); + $output->writeln('[' . date('H:i:s') . '] done p_article_id=' . $pArticleId . ' batch_id=' . $batchId); $msg->ack(); - } catch (\Exception $e) { + } catch (\Throwable $e) { \think\Log::error('reference_check:mq-consume ' . $e->getMessage()); $output->writeln('' . $e->getMessage() . ''); $msg->nack(false, false); diff --git a/application/common/ArticleParserService.php b/application/common/ArticleParserService.php index 2996ed01..c6efeabe 100644 --- a/application/common/ArticleParserService.php +++ b/application/common/ArticleParserService.php @@ -32,10 +32,9 @@ class ArticleParserService // $this->log("✅ 文档直接加载成功,节数量:{$sectionCount}"); $this->phpWord = $reader->load($filePath); $this->sections = $this->phpWord->getSections(); - } catch (\Exception $e) { - // 预处理:移除 DOCX 中的 EMF 图片 + } catch (\Throwable $e) { + // 预处理:移除 EMF、表格内分页符等 PhpWord 不兼容内容后重试 $processedFilePath = $this->removeEmfFromDocx($filePath); - // 加载处理后的文档 $reader = IOFactory::createReader(); $reader->setReadDataOnly(false); Settings::setCompatibility(false); @@ -44,9 +43,9 @@ class ArticleParserService $this->phpWord = $reader->load($processedFilePath); $this->sections = $this->phpWord->getSections(); - // 可选:删除临时处理文件(避免冗余) - unlink($processedFilePath); - return json_encode(['status' => 5, 'msg' => $e->getMessage()]); + if (is_file($processedFilePath)) { + @unlink($processedFilePath); + } } } /** @@ -77,6 +76,10 @@ class ArticleParserService unlink($file->getPathname()); } } + + // 3.1 清理表格单元格内分页符(PhpWord 无法解析,会抛 Cannot add PageBreak in Cell) + $this->sanitizePageBreaksInDocxXmlFiles($tempDir); + // 4. 重新打包为 DOCX $processedPath = $tempDir . '_processed.docx'; $newZip = new ZipArchive(); @@ -94,6 +97,76 @@ class ArticleParserService return $processedPath; } + /** + * 移除 word/*.xml 中表格单元格里的分页符,避免 PhpWord 读取失败 + */ + private function sanitizePageBreaksInDocxXmlFiles($tempDir) + { + $wordDir = rtrim($tempDir, '/\\') . '/word'; + if (!is_dir($wordDir)) { + return; + } + + $xmlFiles = glob($wordDir . '/*.xml'); + if (empty($xmlFiles)) { + return; + } + + foreach ($xmlFiles as $xmlPath) { + $this->sanitizePageBreaksInWordXmlFile($xmlPath); + } + } + + /** + * @param string $xmlPath + */ + private function sanitizePageBreaksInWordXmlFile($xmlPath) + { + if (!is_file($xmlPath) || !is_readable($xmlPath)) { + return; + } + + $xml = file_get_contents($xmlPath); + if ($xml === false || trim($xml) === '') { + return; + } + + $dom = new DOMDocument(); + $dom->preserveWhiteSpace = true; + $dom->formatOutput = false; + if (@$dom->loadXML($xml) === false) { + return; + } + + $xpath = new DOMXPath($dom); + $xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'); + + $queries = [ + '//w:tc//w:br[@w:type="page"]', + '//w:tc//w:lastRenderedPageBreak', + '//w:tc//w:pPr/w:pageBreakBefore', + ]; + + $removed = false; + foreach ($queries as $query) { + $nodes = $xpath->query($query); + if (!$nodes || $nodes->length === 0) { + continue; + } + for ($i = $nodes->length - 1; $i >= 0; $i--) { + $node = $nodes->item($i); + if ($node && $node->parentNode) { + $node->parentNode->removeChild($node); + $removed = true; + } + } + } + + if ($removed) { + file_put_contents($xmlPath, $dom->saveXML()); + } + } + /** * 递归添加目录文件到 ZipArchive * @param string $dir 目录路径 @@ -837,13 +910,18 @@ class ArticleParserService $text .= $textPart; } - // 处理超链接(逻辑不变,保持邮箱优先提取) + // 处理超链接(PhpWord 用 getSource,旧版曾用 getTarget) if ($element instanceof \PhpOffice\PhpWord\Element\Link) { - $target = (string)$element->getTarget(); - if (strpos($target, 'mailto:') === 0) { + $target = ''; + if (method_exists($element, 'getSource')) { + $target = (string) $element->getSource(); + } elseif (method_exists($element, 'getTarget')) { + $target = (string) $element->getTarget(); + } + if ($target !== '' && strpos($target, 'mailto:') === 0) { $text .= rtrim(str_replace('mailto:', '', $target)) . ' '; } - $linkText = strtr((string)$element->getText(), $specialQuotesMap); + $linkText = strtr((string) $element->getText(), $specialQuotesMap); $text .= $linkText . ' '; } @@ -1059,19 +1137,29 @@ class ArticleParserService $result['positions']['abstract'] = $absPos; $absEndPos = $absPos + strlen($abstract); - // 4. 定位 Keywords(需在 Abstract 之后,不区分大小写) - $keyPos = stripos($str, $keywords, $absEndPos); + // 4. 定位 Keywords(需在 Abstract 之后,兼容 Key words / 关键词) + $keyPos = false; + $keyEndPos = 0; + foreach (['keywords', 'key words', '关键词'] as $marker) { + $pos = stripos($str, $marker, $absEndPos); + if ($pos === false) { + continue; + } + if ($keyPos === false || $pos < $keyPos) { + $keyPos = $pos; + $keyEndPos = $pos + strlen($marker); + } + } if ($keyPos === false) { - $result['error'] = "未找到 {$keywords} 或在 {$abstract} 之前"; + $result['error'] = "未找到 keywords 或在 {$abstract} 之前"; return $result; } $result['positions']['keywords'] = $keyPos; - $keyEndPos = $keyPos + strlen($keywords); // 5. 定位 end-span(需在 Keywords 之后,严格匹配) $endPos = strpos($str, $end_span, $keyEndPos); if ($endPos === false) { - $result['error'] = "未找到 {$end_span} 或在 {$keywords} 之前"; + $result['error'] = "未找到 {$end_span} 或在 keywords 之前"; return $result; } $result['positions']['end_span'] = $endPos; @@ -1090,6 +1178,16 @@ class ArticleParserService $part2 = substr($str, $keyEndPos, $len2); $part2 = trim($part2); $part2 = ltrim($part2, ': -—'); + // Keywords 标题独占一段时,正文在下一段 + if ($part2 === '') { + $nextStart = $endPos + strlen($end_span); + $nextEnd = strpos($str, $end_span, $nextStart); + if ($nextEnd !== false) { + $part2 = trim(substr($str, $nextStart, $nextEnd - $nextStart)); + $part2 = ltrim($part2, ': -—'); + $result['positions']['end_span'] = $nextEnd; + } + } $result['keywords_to_end'] = trim($part2); // 7. 标记为有效 @@ -1134,6 +1232,9 @@ class ArticleParserService $abstract = mb_convert_encoding($abstract, 'UTF-8', 'GBK'); } $keywords = empty($result['keywords_to_end']) ? '' : $result['keywords_to_end']; + if ($keywords === '') { + $keywords = self::extractKeywordsFromLines($this->getParagraphLinesFromSections()); + } if(!empty($keywords) && !mb_check_encoding($keywords, 'UTF-8')){ $keywords = mb_convert_encoding($keywords, 'UTF-8', 'GBK'); } @@ -1152,6 +1253,728 @@ class ArticleParserService ]; } + /** + * 从已加载节中提取段落行 + * @return array + */ + private function getParagraphLinesFromSections(): array + { + if (empty($this->sections)) { + return []; + } + + $lines = []; + foreach ($this->sections as $section) { + foreach ($section->getElements() as $element) { + $text = trim((string) $this->getTextFromElement($element)); + if ($text === '') { + continue; + } + if (!mb_check_encoding($text, 'UTF-8')) { + $text = mb_convert_encoding($text, 'UTF-8', 'GBK'); + } + $lines[] = preg_replace('/\s+/u', ' ', $text); + } + } + return $lines; + } + + /** + * 按段落标题行提取关键词(Keywords / Key words / 关键词) + */ + private static function extractKeywordsFromLines(array $lines): string + { + $headingPattern = '/^\s*(keywords?|key\s+words|关键词)\s*[::]?\s*(.*)$/iu'; + $stopPattern = '/^\s*(introduction|background|methods?|materials|results?|discussion|conclusions?|references?|引言|前言|方法|结果|讨论|结论|参考文献)\b/iu'; + + foreach ($lines as $i => $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + if (!preg_match($headingPattern, $line, $match)) { + continue; + } + + $inline = trim((string) ($match[2] ?? '')); + if ($inline !== '') { + return $inline; + } + + for ($j = $i + 1; $j < count($lines); $j++) { + $next = trim((string) $lines[$j]); + if ($next === '') { + continue; + } + if (preg_match($stopPattern, $next)) { + break; + } + return $next; + } + } + + return ''; + } + + /** + * 解析稿件并输出完整结构(含风险指标原始数据) + * @param string $filePath 本地 .docx 路径 + * @return array{status:int,msg:string,data:array} + */ + public static function parseManuscriptStructure($filePath): array + { + $result = self::buildManuscriptAnalysis($filePath); + if (intval($result['status'] ?? 0) !== 1) { + return $result; + } + + unset($result['data']['risk_analysis']); + return $result; + } + + /** + * 编辑辅助:稿件解析 + AI 写作风险综合分析 + */ + public static function analyzeWritingRisk($filePath): array + { + return self::buildManuscriptAnalysis($filePath); + } + + /** + * 获取稿件解析结果(供实时风险检测使用) + * @return array{status:int,msg:string,data:array} + */ + public static function getManuscriptDetectionPayload($filePath): array + { + return self::buildManuscriptAnalysis($filePath); + } + + /** + * 检测英文连续 6 词重复短语(出现大于 3 次;中文仍按 4 字、候选阈值≥2,上层再按 >3 过滤) + */ + public static function detectRepeatedFourWordPhrases(string $text, array $topicPhrases = []): array + { + return self::computeRepeatedPhrases($text, 6, 6, 4, 50, $topicPhrases, 4, 4); + } + + /** + * 检测英文连续 6 词重复(出现 > 3 次) + */ + public static function detectRepeatedSixWordPhrases(string $text, array $topicPhrases = []): array + { + return self::detectRepeatedFourWordPhrases($text, $topicPhrases); + } + + /** + * 从标题、关键词构建主题短语白名单 + * @return array + */ + public static function buildTopicPhrasesForDetection(string $title, string $keywords): array + { + return self::buildTopicPhrases($title, $keywords); + } + + /** + * @return array{status:int,msg:string,data:array} + */ + private static function buildManuscriptAnalysis($filePath): array + { + $filePath = trim((string) $filePath); + if ($filePath === '' || !is_file($filePath) || !is_readable($filePath)) { + return ['status' => 0, 'msg' => '稿件文件不存在或不可读', 'data' => []]; + } + if (strtolower(pathinfo($filePath, PATHINFO_EXTENSION)) !== 'docx') { + return ['status' => 0, 'msg' => '仅支持 .docx 稿件', 'data' => []]; + } + + try { + $parser = new self($filePath); + } catch (\Throwable $e) { + return ['status' => 0, 'msg' => $e->getMessage(), 'data' => []]; + } + if (empty($parser->sections)) { + return ['status' => 0, 'msg' => '稿件解析失败,未读取到文档内容', 'data' => []]; + } + + $title = trim((string) $parser->getTitle()); + if ($title !== '') { + $title = $parser->fullDecode($title); + } + + $extracted = $parser->extractFromWord(); + $meta = empty($extracted['data']) || !is_array($extracted['data']) ? [] : $extracted['data']; + $abstract = empty($meta['abstrart']) ? '' : (string) $meta['abstrart']; + $keywords = empty($meta['keywords']) ? '' : (string) $meta['keywords']; + + $lines = self::collectParagraphLines($filePath); + $sections = self::splitImradSections($lines); + $references = self::getReferencesFromWord($filePath); + + $abstractForDetect = ManuscriptTextCleanService::clean($abstract); + $sectionsForDetect = ManuscriptTextCleanService::cleanSections([ + 'Introduction' => (string) ($sections['Introduction'] ?? ''), + 'Methods' => (string) ($sections['Methods'] ?? ''), + 'Results' => (string) ($sections['Results'] ?? ''), + 'Discussion' => (string) ($sections['Discussion'] ?? ''), + 'Conclusion' => (string) ($sections['Conclusion'] ?? ''), + ]); + + $sectionOrder = ['Introduction', 'Methods', 'Results', 'Discussion', 'Conclusion']; + $bodyParts = []; + $sentenceStatsBySection = []; + foreach ($sectionOrder as $sectionKey) { + $sectionText = empty($sectionsForDetect[$sectionKey]) ? '' : (string) $sectionsForDetect[$sectionKey]; + if ($sectionText !== '') { + $bodyParts[] = $sectionText; + $sentenceStatsBySection[$sectionKey] = self::computeSentenceLengthStats($sectionText); + } + } + $sentenceStats = self::computeSentenceLengthStats(implode("\n\n", $bodyParts)); + $bodyText = implode("\n\n", $bodyParts); + $topicPhrases = self::buildTopicPhrases($title, $keywords); + $repeatedPhrases = self::computeRepeatedPhrases($bodyText, 2, 4, 2, 100, $topicPhrases); + $repeatedPhrasesBySection = []; + foreach ($sectionOrder as $sectionKey) { + $sectionText = empty($sectionsForDetect[$sectionKey]) ? '' : (string) $sectionsForDetect[$sectionKey]; + if ($sectionText !== '') { + $repeatedPhrasesBySection[$sectionKey] = self::computeRepeatedPhrases($sectionText, 2, 4, 2, 100, $topicPhrases); + } + } + + $templateSentenceStats = (new AiTemplateSentenceService())->countInManuscript([ + 'abstract' => $abstractForDetect, + 'introduction' => $sectionsForDetect['Introduction'], + 'methods' => $sectionsForDetect['Methods'], + 'results' => $sectionsForDetect['Results'], + 'discussion' => $sectionsForDetect['Discussion'], + 'conclusion' => $sectionsForDetect['Conclusion'], + ]); + + $manuscript = [ + 'title' => $title, + 'abstract' => $abstract, + 'keywords' => $keywords, + 'Introduction' => $sections['Introduction'], + 'Methods' => $sections['Methods'], + 'Results' => $sections['Results'], + 'Discussion' => $sections['Discussion'], + 'Conclusion' => $sections['Conclusion'], + 'References' => $references, + ]; + + $metrics = [ + 'sentence_stats' => $sentenceStats, + 'sentence_stats_by_section' => $sentenceStatsBySection, + 'repeated_phrases' => $repeatedPhrases, + 'repeated_phrases_by_section' => $repeatedPhrasesBySection, + 'template_sentence_stats' => $templateSentenceStats, + ]; + + $riskAnalysis = (new AiWritingRiskAnalysisService())->buildReport(array_merge($manuscript, $metrics)); + + return [ + 'status' => 1, + 'msg' => 'success', + 'data' => array_merge($manuscript, $metrics, [ + 'risk_analysis' => $riskAnalysis, + ]), + ]; + } + + /** + * 统计文本句长:平均、标准差、最长、最短(英文按词数,中文按字数) + * @return array{avg:float,std:float,max:int,min:int,sentence_count:int} + */ + private static function computeSentenceLengthStats(string $text): array + { + $empty = ['avg' => 0, 'std' => 0, 'max' => 0, 'min' => 0, 'sentence_count' => 0]; + $sentences = self::splitSentences($text); + if (empty($sentences)) { + return $empty; + } + + $lengths = []; + foreach ($sentences as $sentence) { + $len = self::measureSentenceLength($sentence); + if ($len > 0) { + $lengths[] = $len; + } + } + if (empty($lengths)) { + return $empty; + } + + $count = count($lengths); + $avg = array_sum($lengths) / $count; + $variance = 0.0; + foreach ($lengths as $len) { + $variance += ($len - $avg) ** 2; + } + $std = $count > 1 ? sqrt($variance / ($count - 1)) : 0.0; + + return [ + 'avg' => round($avg, 2), + 'std' => round($std, 2), + 'max' => max($lengths), + 'min' => min($lengths), + 'sentence_count' => $count, + ]; + } + + /** + * 切分句子(支持中英文句末标点) + * @return array + */ + private static function splitSentences(string $text): array + { + $text = trim($text); + if ($text === '') { + return []; + } + $text = preg_replace('/\s+/u', ' ', $text); + $parts = preg_split('/(?<=[\.\?\!。!?])\s+/u', $text); + if ($parts === false) { + return []; + } + + $sentences = []; + foreach ($parts as $part) { + $part = trim((string) $part); + if ($part !== '') { + $sentences[] = $part; + } + } + return $sentences; + } + + /** + * 单句长度:英文按词数,纯中文按字数,混合文本取词数+汉字数 + */ + private static function measureSentenceLength(string $sentence): int + { + $sentence = trim($sentence); + if ($sentence === '') { + return 0; + } + + $cjkCount = 0; + if (preg_match_all('/\p{Han}/u', $sentence, $cjkMatches)) { + $cjkCount = count($cjkMatches[0]); + } + + $wordCount = 0; + if (preg_match_all('/[a-zA-Z0-9]+(?:[\'’\-][a-zA-Z0-9]+)*/u', $sentence, $wordMatches)) { + $wordCount = count($wordMatches[0]); + } + + if ($wordCount > 0 || $cjkCount > 0) { + return $wordCount + $cjkCount; + } + + return mb_strlen(preg_replace('/\s+/u', '', $sentence)); + } + + /** + * 统计重复短语(英文按词 n-gram,中文按连续汉字 n-gram) + * @param int|null $cjkMinLen 中文最小长度,null 则同 $minLen + * @param int|null $cjkMaxLen 中文最大长度,null 则同 $maxLen + * @return array{items:array,unique_count:int,total_occurrences:int} + */ + private static function computeRepeatedPhrases( + string $text, + int $minLen = 2, + int $maxLen = 4, + int $minCount = 2, + int $limit = 100, + array $topicPhrases = [], + ?int $cjkMinLen = null, + ?int $cjkMaxLen = null + ): array { + $empty = [ + 'items' => [], + 'suspicious_items' => [], + 'expected_topic_items' => [], + 'unique_count' => 0, + 'suspicious_count' => 0, + 'total_occurrences' => 0, + ]; + $text = trim($text); + if ($text === '') { + return $empty; + } + + $cjkMin = $cjkMinLen === null ? $minLen : $cjkMinLen; + $cjkMax = $cjkMaxLen === null ? $maxLen : $cjkMaxLen; + + $counts = []; + self::collectEnglishPhraseCounts($text, $counts, $minLen, $maxLen, $topicPhrases); + self::collectCjkPhraseCounts($text, $counts, $cjkMin, $cjkMax); + + $items = []; + $suspiciousItems = []; + $expectedItems = []; + foreach ($counts as $row) { + if (intval($row['count']) < $minCount) { + continue; + } + $items[] = $row; + if (($row['category'] ?? '') === 'expected_topic') { + $expectedItems[] = $row; + } elseif (($row['category'] ?? '') === 'suspicious') { + $suspiciousItems[] = $row; + } + } + if (empty($items)) { + return $empty; + } + + $sortFn = function ($a, $b) { + if ($a['count'] !== $b['count']) { + return $b['count'] - $a['count']; + } + if ($a['length'] !== $b['length']) { + return $b['length'] - $a['length']; + } + return strcmp($a['phrase'], $b['phrase']); + }; + usort($items, $sortFn); + usort($suspiciousItems, $sortFn); + usort($expectedItems, $sortFn); + + if (count($items) > $limit) { + $items = array_slice($items, 0, $limit); + } + if (count($suspiciousItems) > $limit) { + $suspiciousItems = array_slice($suspiciousItems, 0, $limit); + } + + $totalOccurrences = 0; + foreach ($items as $item) { + $totalOccurrences += intval($item['count']); + } + + return [ + 'items' => $items, + 'suspicious_items' => $suspiciousItems, + 'expected_topic_items' => $expectedItems, + 'unique_count' => count($items), + 'suspicious_count' => count($suspiciousItems), + 'total_occurrences' => $totalOccurrences, + ]; + } + + /** + * 从标题、关键词提取主题短语,用于排除正常重复 + * @return array + */ + private static function buildTopicPhrases(string $title, string $keywords): array + { + $parts = preg_split('/[;;,,\n]+/u', strtolower($title . ';' . $keywords)); + $phrases = []; + foreach ($parts as $part) { + $part = trim((string) preg_replace('/\s+/u', ' ', $part)); + if ($part === '') { + continue; + } + if (!preg_match_all('/[a-z0-9]+(?:[\'’\-][a-z0-9]+)*/u', $part, $matches)) { + continue; + } + $words = $matches[0]; + $wordCount = count($words); + for ($n = 2; $n <= min(4, $wordCount); $n++) { + for ($i = 0; $i <= $wordCount - $n; $i++) { + $phrases[] = implode(' ', array_slice($words, $i, $n)); + } + } + } + return array_values(array_unique($phrases)); + } + + private static function classifyRepeatedPhrase(string $phrase, array $topicPhrases): string + { + if (self::isMethodologyNoisePhrase($phrase)) { + return 'noise'; + } + if (self::isTopicExpectedPhrase($phrase, $topicPhrases)) { + return 'expected_topic'; + } + if (self::hasEdgeStopword($phrase)) { + return 'noise'; + } + return 'suspicious'; + } + + private static function isMethodologyNoisePhrase(string $phrase): bool + { + static $patterns = [ + '/^title abstract\b/u', + '/\babstract or\b/u', + '/\b(title|abstract|mesh|emtree|pubmed|cochrane|cinahl|scopus)\b/u', + '/\b(search strategy|search terms?|systematic search)\b/u', + '/\b(prisma|randomized controlled|inclusion criteria|exclusion criteria)\b/u', + ]; + foreach ($patterns as $pattern) { + if (preg_match($pattern, $phrase)) { + return true; + } + } + return false; + } + + private static function isTopicExpectedPhrase(string $phrase, array $topicPhrases): bool + { + if (in_array($phrase, $topicPhrases, true)) { + return true; + } + foreach ($topicPhrases as $topic) { + if ($topic !== '' && strpos($topic, $phrase) !== false && substr_count($phrase, ' ') >= 1) { + return true; + } + } + return false; + } + + private static function hasEdgeStopword(string $phrase): bool + { + static $stopwords = [ + 'the', 'a', 'an', 'and', 'or', 'of', 'in', 'on', 'at', 'to', 'for', 'with', 'by', 'from', 'as', + 'is', 'was', 'were', 'be', 'been', 'being', 'that', 'this', 'these', 'those', 'it', 'its', + 'we', 'our', 'they', 'their', 'he', 'she', 'his', 'her', 'are', 'has', 'have', 'had', + ]; + $words = preg_split('/\s+/u', trim($phrase)); + if (empty($words)) { + return true; + } + $first = $words[0]; + $last = $words[count($words) - 1]; + return in_array($first, $stopwords, true) || in_array($last, $stopwords, true); + } + + /** + * 英文词序列 n-gram 计数 + */ + private static function collectEnglishPhraseCounts(string $text, array &$counts, int $minLen, int $maxLen, array $topicPhrases = []): void + { + $text = strtolower($text); + if (!preg_match_all('/[a-z0-9]+(?:[\'’\-][a-z0-9]+)*/u', $text, $matches)) { + return; + } + + $words = []; + foreach ($matches[0] as $word) { + if (strlen($word) === 1 && !ctype_digit($word)) { + continue; + } + $words[] = $word; + } + $wordCount = count($words); + if ($wordCount < $minLen) { + return; + } + + for ($n = $minLen; $n <= $maxLen; $n++) { + for ($i = 0; $i <= $wordCount - $n; $i++) { + $slice = array_slice($words, $i, $n); + if (self::isStopwordOnlyPhrase($slice)) { + continue; + } + $phrase = implode(' ', $slice); + $category = self::classifyRepeatedPhrase($phrase, $topicPhrases); + if ($category === 'noise') { + continue; + } + if (!isset($counts[$phrase])) { + $counts[$phrase] = [ + 'phrase' => $phrase, + 'count' => 0, + 'length' => $n, + 'type' => 'en', + 'category' => $category, + ]; + } + $counts[$phrase]['count']++; + } + } + } + + /** + * 中文连续汉字 n-gram 计数 + */ + private static function collectCjkPhraseCounts(string $text, array &$counts, int $minLen, int $maxLen): void + { + if (!preg_match_all('/\p{Han}+/u', $text, $blocks)) { + return; + } + + foreach ($blocks[0] as $block) { + $chars = preg_split('//u', $block, -1, PREG_SPLIT_NO_EMPTY); + if (!is_array($chars)) { + continue; + } + $charCount = count($chars); + if ($charCount < $minLen) { + continue; + } + + for ($n = $minLen; $n <= $maxLen; $n++) { + for ($i = 0; $i <= $charCount - $n; $i++) { + $phrase = implode('', array_slice($chars, $i, $n)); + if (!isset($counts[$phrase])) { + $counts[$phrase] = [ + 'phrase' => $phrase, + 'count' => 0, + 'length' => $n, + 'type' => 'zh', + ]; + } + $counts[$phrase]['count']++; + } + } + } + } + + /** + * 是否全为英文停用词(过滤无意义短语) + */ + private static function isStopwordOnlyPhrase(array $words): bool + { + static $stopwords = [ + 'the', 'a', 'an', 'and', 'or', 'of', 'in', 'on', 'at', 'to', 'for', 'with', 'by', 'from', 'as', + 'is', 'was', 'were', 'be', 'been', 'being', 'that', 'this', 'these', 'those', 'it', 'its', + 'we', 'our', 'they', 'their', 'he', 'she', 'his', 'her', 'are', 'has', 'have', 'had', + ]; + + foreach ($words as $word) { + if (!in_array($word, $stopwords, true)) { + return false; + } + } + return true; + } + + /** + * 按 IMRaD 标题将段落行切分为各节正文 + * @param array $lines + * @return array + */ + private static function splitImradSections(array $lines): array + { + $sectionOrder = ['Introduction', 'Methods', 'Results', 'Discussion', 'Conclusion']; + $buffers = array_fill_keys($sectionOrder, []); + $currentKey = null; + $refStopPattern = '/^\s*(references|reference|bibliography|参考文献|文献)\b\s*[::]?\s*/iu'; + + foreach ($lines as $line) { + $t = trim((string) $line); + if ($t === '') { + if ($currentKey !== null) { + $buffers[$currentKey][] = ''; + } + continue; + } + if (preg_match($refStopPattern, $t)) { + break; + } + + $detected = self::detectSectionKey($t); + if ($detected !== null) { + $currentKey = $detected; + $remainder = self::extractSectionRemainder($t, $detected); + if ($remainder !== '') { + $buffers[$currentKey][] = $remainder; + } + continue; + } + + if ($currentKey !== null) { + $buffers[$currentKey][] = $t; + } + } + + $sections = array_fill_keys($sectionOrder, ''); + foreach ($sectionOrder as $key) { + if (!empty($buffers[$key])) { + $sections[$key] = trim(implode("\n", $buffers[$key])); + } + } + return $sections; + } + + /** + * 识别段落是否为 IMRaD 节标题 + */ + private static function detectSectionKey($line): ?string + { + $patterns = [ + 'Introduction' => [ + '/^\s*(?:\d+[\.\s、]+)?(introduction|background)\b\s*[::]?\s*(.*)$/iu', + '/^\s*(引言|前言|背景)\s*[::]?\s*(.*)$/u', + ], + 'Methods' => [ + '/^\s*(?:\d+[\.\s、]+)?(methods?|materials\s+and\s+methods|materials\s*&\s*methods|methodology|experimental(?:\s+section)?)\b\s*[::]?\s*(.*)$/iu', + '/^\s*(方法|材料与方法|资料与方法|研究方法|实验方法)\s*[::]?\s*(.*)$/u', + ], + 'Results' => [ + '/^\s*(?:\d+[\.\s、]+)?(results?|findings)\b\s*[::]?\s*(.*)$/iu', + '/^\s*(结果|研究结果)\s*[::]?\s*(.*)$/u', + ], + 'Discussion' => [ + '/^\s*(?:\d+[\.\s、]+)?(discussion|discussions)\b\s*[::]?\s*(.*)$/iu', + '/^\s*(讨论|商榷)\s*[::]?\s*(.*)$/u', + ], + 'Conclusion' => [ + '/^\s*(?:\d+[\.\s、]+)?(conclusions?|summary|concluding\s+remarks)\b\s*[::]?\s*(.*)$/iu', + '/^\s*(结论|结语|总结)\s*[::]?\s*(.*)$/u', + ], + ]; + + foreach ($patterns as $key => $regexList) { + foreach ($regexList as $regex) { + if (preg_match($regex, $line)) { + return $key; + } + } + } + return null; + } + + /** + * 节标题同行后的正文残余(如 "Introduction: xxx") + */ + private static function extractSectionRemainder($line, $sectionKey): string + { + $patterns = [ + 'Introduction' => [ + '/^\s*(?:\d+[\.\s、]+)?(?:introduction|background)\b\s*[::]?\s*(.*)$/iu', + '/^\s*(?:引言|前言|背景)\s*[::]?\s*(.*)$/u', + ], + 'Methods' => [ + '/^\s*(?:\d+[\.\s、]+)?(?:methods?|materials\s+and\s+methods|materials\s*&\s*methods|methodology|experimental(?:\s+section)?)\b\s*[::]?\s*(.*)$/iu', + '/^\s*(?:方法|材料与方法|资料与方法|研究方法|实验方法)\s*[::]?\s*(.*)$/u', + ], + 'Results' => [ + '/^\s*(?:\d+[\.\s、]+)?(?:results?|findings)\b\s*[::]?\s*(.*)$/iu', + '/^\s*(?:结果|研究结果)\s*[::]?\s*(.*)$/u', + ], + 'Discussion' => [ + '/^\s*(?:\d+[\.\s、]+)?(?:discussion|discussions)\b\s*[::]?\s*(.*)$/iu', + '/^\s*(?:讨论|商榷)\s*[::]?\s*(.*)$/u', + ], + 'Conclusion' => [ + '/^\s*(?:\d+[\.\s、]+)?(?:conclusions?|summary|concluding\s+remarks)\b\s*[::]?\s*(.*)$/iu', + '/^\s*(?:结论|结语|总结)\s*[::]?\s*(.*)$/u', + ], + ]; + + $regexList = empty($patterns[$sectionKey]) ? [] : $patterns[$sectionKey]; + foreach ($regexList as $regex) { + if (preg_match($regex, $line, $m)) { + return trim((string) ($m[1] ?? '')); + } + } + return ''; + } + /** * 按段落提取 Word 全文行(供正文裁切、参考文献识别等复用) * @return array diff --git a/application/common/BackgroundCheckService.php b/application/common/BackgroundCheckService.php index 78ca5426..8f68bf43 100644 --- a/application/common/BackgroundCheckService.php +++ b/application/common/BackgroundCheckService.php @@ -104,6 +104,64 @@ class BackgroundCheckService return ['count' => count($list), 'list' => $list, 'source' => 'openalex']; } + /** + * 按 DOI 从 OpenAlex 获取文献及作者身份(用于引用堆叠精准作者定位) + */ + public function fetchOpenAlexWorkByDoi($doi) + { + $doi = $this->cleanDoi($doi); + if ($doi === '') { + return ['success' => false, 'error' => 'DOI为空']; + } + + $res = $this->openAlexGet('/works/https://doi.org/' . rawurlencode($doi)); + if (!$res['success']) { + return $res; + } + + return ['success' => true, 'work' => $res['data']]; + } + + /** + * 解析 OpenAlex work 中的作者身份(OpenAlex ID + ORCID) + * + * @return array + */ + public function parseWorkAuthorships(array $work) + { + $list = []; + foreach ($work['authorships'] ?? [] as $auth) { + if (!is_array($auth)) { + continue; + } + $author = is_array($auth['author'] ?? null) ? $auth['author'] : []; + $openalexId = $this->extractOpenAlexId($author['id'] ?? ''); + $orcid = $this->cleanOrcid($author['orcid'] ?? ''); + if ($openalexId === '' && $orcid === '') { + continue; + } + + $identityKeys = []; + if ($openalexId !== '') { + $identityKeys[] = 'openalex:' . $openalexId; + } + if ($orcid !== '') { + $identityKeys[] = 'orcid:' . $orcid; + } + + $list[] = [ + 'openalex_id' => $openalexId, + 'orcid' => $orcid, + 'display_name' => trim((string)($author['display_name'] ?? '')), + 'author_position' => (string)($auth['author_position'] ?? ''), + 'is_corresponding' => !empty($auth['is_corresponding']), + 'identity_keys' => $identityKeys, + ]; + } + + return $list; + } + public function fetchRecentWorks($openAlexId, $limit = 5) { $res = $this->openAlexGet('/works', [ @@ -604,7 +662,7 @@ class BackgroundCheckService ]; } - private function parseCrossRefAuthors($authorList) + public function parseCrossRefAuthors($authorList) { if (empty($authorList) || !is_array($authorList)) { return []; @@ -612,16 +670,52 @@ class BackgroundCheckService $result = []; foreach ($authorList as $a) { + $orcid = ''; + if (!empty($a['ORCID']) && is_array($a['ORCID'])) { + $orcid = $this->cleanOrcid((string)($a['ORCID'][0] ?? '')); + } elseif (!empty($a['ORCID']) && is_string($a['ORCID'])) { + $orcid = $this->cleanOrcid($a['ORCID']); + } + $result[] = [ 'given' => $a['given'] ?? '', 'family' => $a['family'] ?? '', 'name' => isset($a['name']) ? $a['name'] : trim(($a['given'] ?? '') . ' ' . ($a['family'] ?? '')), - 'orcid' => $a['ORCID'] ?? '', + 'orcid' => $orcid, ]; } return $result; } + /** + * Crossref 作者列表 → 带 identity_keys 的结构(ORCID 兜底) + * + * @return array + */ + public function authorshipsFromCrossrefAuthors(array $authorList) + { + $parsed = $this->parseCrossRefAuthors($authorList); + $list = []; + $total = count($parsed); + foreach ($parsed as $i => $a) { + $orcid = trim((string)($a['orcid'] ?? '')); + if ($orcid === '') { + continue; + } + $displayName = trim((string)($a['name'] ?? '')); + $position = ($i === 0) ? 'first' : (($i === $total - 1) ? 'last' : 'middle'); + $list[] = [ + 'openalex_id' => '', + 'orcid' => $orcid, + 'display_name' => $displayName, + 'author_position' => $position, + 'is_corresponding' => false, + 'identity_keys' => ['orcid:' . $orcid], + ]; + } + return $list; + } + private function parseDateParts($dateObj) { if (!isset($dateObj['date-parts'][0])) { diff --git a/application/common/CmaJournalLiteratureService.php b/application/common/CmaJournalLiteratureService.php new file mode 100644 index 00000000..b12047cd --- /dev/null +++ b/application/common/CmaJournalLiteratureService.php @@ -0,0 +1,329 @@ +mailto = trim((string)$config['mailto']); + } else { + $this->mailto = trim((string)Env::get('crossref_mailto', Env::get('pubmed_email', ''))); + } + $this->yiigleApiBase = rtrim(trim((string)($config['yiigle_api_base'] ?? Env::get('yiigle_api_base', ''))), '/'); + $this->yiigleApiKey = trim((string)($config['yiigle_api_key'] ?? Env::get('yiigle_api_key', ''))); + $this->skipYiigle = !empty($config['skip_yiigle']); + } + + public static function isCmaJournalDoi($doi) + { + $doi = strtolower(trim((string)$doi)); + + return $doi !== '' && strpos($doi, 'cma.j.cn') !== false; + } + + /** + * @return array{ + * title:string,journal:string,year:string,abstract:string,content:string, + * pmid:string,blocks:array,sources:array,fetch_log:string + * }|null + */ + public function fetchByDoi($doi) + { + $doi = $this->normalizeDoi($doi); + if ($doi === '' || !self::isCmaJournalDoi($doi)) { + return null; + } + + $cacheKey = 'cma_lit_' . sha1(strtolower($doi)); + $cached = $this->cacheGet($cacheKey, 7 * 86400); + if (is_array($cached)) { + return $cached; + } + + $blocks = []; + $sources = []; + $abstract = ''; + $content = ''; + $title = ''; + $journal = ''; + $year = ''; + $pmid = ''; + $logs = []; + + if (!$this->skipYiigle && $this->yiigleApiBase !== '' && $this->yiigleApiKey !== '') { + $yiigle = $this->fetchFromYiigleApi($doi); + if (is_array($yiigle)) { + $sources[] = 'cma_yiigle'; + $title = $this->pickNonEmpty($title, $yiigle['title'] ?? ''); + $journal = $this->pickNonEmpty($journal, $yiigle['journal'] ?? ''); + $year = $this->pickNonEmpty($year, $yiigle['year'] ?? ''); + if (trim((string)($yiigle['abstract'] ?? '')) !== '') { + $abstract = trim((string)$yiigle['abstract']); + } + if (trim((string)($yiigle['content'] ?? '')) !== '') { + $content = trim((string)$yiigle['content']); + } + if (!empty($yiigle['block'])) { + $blocks[] = (string)$yiigle['block']; + } + $logs[] = 'yiigle=' . ($yiigle['status'] ?? 'ok'); + } + } + + $openAlex = $this->fetchFromOpenAlex($doi); + if (is_array($openAlex)) { + $sources[] = 'cma_openalex'; + $title = $this->pickNonEmpty($title, $openAlex['title'] ?? ''); + $journal = $this->pickNonEmpty($journal, $openAlex['journal'] ?? ''); + $year = $this->pickNonEmpty($year, $openAlex['year'] ?? ''); + $pmid = $this->pickNonEmpty($pmid, $openAlex['pmid'] ?? ''); + if ($abstract === '' && trim((string)($openAlex['abstract'] ?? '')) !== '') { + $abstract = trim((string)$openAlex['abstract']); + } + if (!empty($openAlex['block'])) { + $blocks[] = (string)$openAlex['block']; + } + $logs[] = 'openalex=' . ($abstract !== '' ? 'abstract' : 'meta'); + } + + if ($abstract === '' && $content === '' && empty($blocks)) { + return null; + } + + $result = [ + 'title' => $title, + 'journal' => $journal, + 'year' => $year, + 'abstract' => $abstract, + 'content' => $content, + 'pmid' => $pmid, + 'blocks' => array_values(array_filter($blocks)), + 'sources' => array_values(array_unique($sources)), + 'fetch_log' => 'cma_doi=' . $doi . '; ' . implode('; ', $logs), + ]; + $this->cacheSet($cacheKey, $result); + + return $result; + } + + private function fetchFromOpenAlex($doi) + { + $url = 'https://api.openalex.org/works/https://doi.org/' . rawurlencode($doi); + if ($this->mailto !== '') { + $url .= '?mailto=' . rawurlencode($this->mailto); + } + + $raw = $this->httpGet($url); + $json = json_decode((string)$raw, true); + if (!is_array($json)) { + return null; + } + + $title = trim((string)($json['title'] ?? $json['display_name'] ?? '')); + $journal = trim((string)($json['primary_location']['raw_source_name'] ?? '')); + if ($journal === '') { + $journal = trim((string)($json['primary_location']['source']['display_name'] ?? '')); + } + if (strcasecmp($journal, 'PubMed') === 0) { + $journal = ''; + } + $year = trim((string)($json['publication_year'] ?? '')); + $abstract = $this->reconstructOpenAlexAbstract($json['abstract_inverted_index'] ?? null); + if ($abstract === '') { + $abstract = trim((string)($json['abstract'] ?? '')); + } + + $pmid = ''; + $pmidRaw = (string)($json['ids']['pmid'] ?? ''); + if (preg_match('/(\d+)/', $pmidRaw, $m)) { + $pmid = $m[1]; + } + + $lines = ['=== 中华医学期刊 / OpenAlex (DOI ' . $doi . ') ===']; + if ($title !== '') { + $lines[] = 'Title: ' . $title; + } + if ($journal !== '') { + $lines[] = 'Journal: ' . $journal; + } + if ($year !== '') { + $lines[] = 'Year: ' . $year; + } + if ($abstract !== '') { + $lines[] = 'Abstract: ' . $abstract; + } + + return [ + 'title' => $title, + 'journal' => $journal, + 'year' => $year, + 'abstract' => $abstract, + 'pmid' => $pmid, + 'block' => implode("\n", $lines), + ]; + } + + /** + * 可选:机构购买的 Yiigle 资源 API(未配置则跳过)。 + */ + private function fetchFromYiigleApi($doi) + { + $url = $this->yiigleApiBase . '/resource/queryByDoi?doi=' . rawurlencode($doi); + $raw = $this->httpGet($url, [ + 'Authorization: Bearer ' . $this->yiigleApiKey, + 'Accept: application/json', + ]); + $json = json_decode((string)$raw, true); + if (!is_array($json)) { + return ['status' => 'empty']; + } + + $data = $json['data'] ?? $json; + if (!is_array($data)) { + return ['status' => 'invalid']; + } + + $abstract = trim((string)($data['abstract'] ?? $data['summary'] ?? '')); + $content = trim((string)($data['content'] ?? $data['fullText'] ?? '')); + $title = trim((string)($data['title'] ?? '')); + $journal = trim((string)($data['journal'] ?? $data['journalName'] ?? '')); + $year = trim((string)($data['year'] ?? $data['pubYear'] ?? '')); + + $block = ''; + if ($abstract !== '' || $content !== '') { + $lines = ['=== 中华医学期刊网 Yiigle (DOI ' . $doi . ') ===']; + if ($title !== '') { + $lines[] = 'Title: ' . $title; + } + if ($abstract !== '') { + $lines[] = 'Abstract: ' . $abstract; + } + if ($content !== '') { + $lines[] = 'Content: ' . mb_substr($content, 0, 8000); + } + $block = implode("\n", $lines); + } + + return [ + 'status' => ($abstract !== '' || $content !== '') ? 'ok' : 'no_text', + 'title' => $title, + 'journal' => $journal, + 'year' => $year, + 'abstract' => $abstract, + 'content' => $content, + 'block' => $block, + ]; + } + + private function reconstructOpenAlexAbstract($invertedIndex) + { + if (!is_array($invertedIndex) || empty($invertedIndex)) { + return ''; + } + + $tokens = []; + foreach ($invertedIndex as $token => $positions) { + if (!is_array($positions)) { + continue; + } + foreach ($positions as $pos) { + $tokens[intval($pos)] = (string)$token; + } + } + if (empty($tokens)) { + return ''; + } + ksort($tokens, SORT_NUMERIC); + + return trim(implode(' ', $tokens)); + } + + private function normalizeDoi($doi) + { + $doi = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', trim((string)$doi)); + return trim($doi, " \t\n\r\0\x0B/"); + } + + private function pickNonEmpty($current, $candidate) + { + $current = trim((string)$current); + $candidate = trim((string)$candidate); + if ($current !== '') { + return $current; + } + + return $candidate; + } + + private function httpGet($url, array $headers = []) + { + $headers = array_merge([ + 'User-Agent: TMRjournals-CmaJournal/1.0', + 'Accept: application/json, text/plain, */*', + ], $headers); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_HTTPHEADER => $headers, + ]); + $body = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($body === false || $code < 200 || $code >= 300) { + return ''; + } + + return is_string($body) ? $body : ''; + } + + private function cacheDir() + { + return rtrim(ROOT_PATH, '/') . '/runtime/cma_journal_cache'; + } + + private function cacheGet($key, $ttlSeconds) + { + $file = $this->cacheDir() . '/' . $key . '.json'; + if (!is_file($file)) { + return null; + } + $mtime = filemtime($file); + if (!$mtime || (time() - $mtime) > $ttlSeconds) { + return null; + } + $decoded = json_decode((string)@file_get_contents($file), true); + + return is_array($decoded) ? $decoded : null; + } + + private function cacheSet($key, array $value) + { + $dir = $this->cacheDir(); + if (!is_dir($dir)) { + @mkdir($dir, 0777, true); + } + @file_put_contents($dir . '/' . $key . '.json', json_encode($value, JSON_UNESCAPED_UNICODE)); + } +} diff --git a/application/common/DbReconnectHelper.php b/application/common/DbReconnectHelper.php new file mode 100644 index 00000000..0f199b4d --- /dev/null +++ b/application/common/DbReconnectHelper.php @@ -0,0 +1,55 @@ +searchFirst($query); + } + + public function searchByBibliographic($title, $author, $year = '') + { + $title = trim((string)$title); + if ($title === '') { + return null; + } + $parts = ['TITLE:"' . str_replace('"', '', $title) . '"']; + $author = trim((string)$author); + if ($author !== '') { + $firstAuthor = preg_split('/[,;]/', $author); + $firstAuthor = trim((string)($firstAuthor[0] ?? '')); + if ($firstAuthor !== '') { + $parts[] = 'AUTH:"' . str_replace('"', '', $firstAuthor) . '"'; + } + } + $year = trim((string)$year); + if ($year !== '' && preg_match('/^(19|20)\d{2}$/', $year)) { + $parts[] = 'PUB_YEAR:' . $year; + } + return $this->searchFirst(implode(' AND ', $parts)); + } + + public function fetchFullTextByPmcid($pmcid) + { + $pmcid = strtoupper(trim((string)$pmcid)); + if ($pmcid === '') { + return ''; + } + if (strpos($pmcid, 'PMC') !== 0) { + $pmcid = 'PMC' . preg_replace('/\D/', '', $pmcid); + } + $url = $this->base . '/' . rawurlencode($pmcid) . '/fullTextXML'; + $xml = $this->httpGet($url); + if ($xml === '') { + return ''; + } + return $this->xmlToPlainText($xml); + } + + private function searchFirst($query) + { + $url = $this->base . '/search?' . http_build_query([ + 'query' => $query, + 'format' => 'json', + 'pageSize' => 1, + ]); + $raw = $this->httpGet($url); + if ($raw === '') { + return null; + } + $json = json_decode($raw, true); + $list = $json['resultList']['result'] ?? []; + if (empty($list[0]) || !is_array($list[0])) { + return null; + } + $row = $list[0]; + return [ + 'title' => trim((string)($row['title'] ?? '')), + 'abstract' => trim((string)($row['abstractText'] ?? '')), + 'doi' => trim((string)($row['doi'] ?? '')), + 'pmid' => trim((string)($row['pmid'] ?? '')), + 'pmcid' => trim((string)($row['pmcid'] ?? '')), + 'journal' => trim((string)($row['journalTitle'] ?? '')), + 'year' => trim((string)($row['pubYear'] ?? '')), + 'source' => 'europe_pmc', + ]; + } + + private function xmlToPlainText($xml) + { + $xml = trim((string)$xml); + if ($xml === '') { + return ''; + } + libxml_use_internal_errors(true); + $doc = new \DOMDocument(); + if (!$doc->loadXML($xml)) { + return trim(strip_tags($xml)); + } + return trim(preg_replace('/\s+/u', ' ', $doc->textContent)); + } + + private function httpGet($url) + { + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_HTTPHEADER => ['User-Agent: TMRjournals-EuropePMC/1.0'], + ]); + $res = curl_exec($ch); + curl_close($ch); + return is_string($res) ? $res : ''; + } +} diff --git a/application/common/Journal.php b/application/common/Journal.php new file mode 100644 index 00000000..e7f5f7fd --- /dev/null +++ b/application/common/Journal.php @@ -0,0 +1,512 @@ +request->post(); + $user_info = $this->user_obj->where("account",$data['username'])->find(); + $journalIds = $this->reviewer_to_journal_obj->where('reviewer_id',$user_info['user_id'])->where('state',0)->column('journal_id'); + $list = $this->journal_obj->where('journal_id',"not in",$journalIds)->where('state',0)->select(); + + $re['journals'] = $list; + return jsonSuccess($re); + } + + /** + * @title 获取审稿人所属期刊列表 + * @description 获取审稿人所属期刊列表 + * @author wangjinlei + * @url /api/Journal/getJournalInReviewer + * @method POST + * + * @param name:username type:string require:1 desc:用户名 + * + * @return journals:期刊列表# + */ + public function getJournalInReviewer(){ + $data = $this->request->post(); + $user_info = $this->user_obj->where('account',$data['username'])->where('state',0)->find(); + $list = $this->reviewer_to_journal_obj + ->field("t_journal.*") + ->join('t_journal',"t_journal.journal_id = t_reviewer_to_journal.journal_id","left") + ->where('t_reviewer_to_journal.reviewer_id',$user_info['user_id']) + ->where('t_reviewer_to_journal.state',0) + ->select(); + + $re['journals'] = $list; + return jsonSuccess($re); + } + + public function getAllJournal(){ + $list = $this->journal_obj->where('state',0)->select(); + //接口请求获取journal_topic 和 abstract_chinese chengxiaoling 20250514 start + if(!empty($list)){ + $list = $this->_getJournalForApi($list); + } + //接口请求获取journal_topic 和 abstract_chinese chengxiaoling 20250514 end + $re['journals'] = $list; + return jsonSuccess($re); + } + + + /**获取连续出刊的当年分期信息 + * @return void + */ + public function getJournalStageLX(){ + $data = $this->request->post(); + $rule = new Validate([ + "journal_id"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $journal_info = $this->journal_obj->where("journal_id",$data['journal_id'])->find(); + $url = "http://journalapi.tmrjournals.com/public/index.php/master/Journal/getJournalStageLXForSubmission"; + $program['issn'] = $journal_info['issn']; + $res = object_to_array(json_decode(myPost($url,$program))); + $list = $res['data']['detail']; + $re['detail'] = $list; + return jsonSuccess($re); + } + + + public function creatJournalStage(){ + $data = $this->request->post(); + $rule = new Validate([ + "issn"=>"require", + "stage_year"=>"require", + "stage_vol"=>"require", + "stage_no"=>"require", + "stage_page"=>"require", + "issue_date"=>"require", + "stage_icon"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + + $url = "http://journalapi.tmrjournals.com/public/index.php/master/Journal/addStageForTG"; + $program['issn'] = $data['issn']; + $program['stage_year'] = $data['stage_year']; + $program['stage_vol'] = $data['stage_vol']; + $program['stage_no'] = $data['stage_no']; + $program['stage_pagename'] = "No."; + $program['stage_page'] = $data['stage_page']; + $program['issue_date'] = $data['issue_date']; + $program['stage_icon'] = $data['stage_icon']; + object_to_array(json_decode(myPost($url,$program))); + return jsonSuccess($program); + } + + public function citeMate(){ + $data = $this->request->post(); + $rule = new Validate([ + "journal_id"=>"require", + "year"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $journal_info = $this->journal_obj->where("journal_id",$data['journal_id'])->find(); + $url = "http://journalapi.tmrjournals.com/public/index.php/api/Main/citeMate"; + $program['journal_issn'] = $journal_info['issn']; + $program['year'] = $data['year']; + $res = object_to_array(json_decode(myPost($url,$program))); + + return json($res); + } + + public function delJournalStage(){ + $data = $this->request->post(); + $rule = new Validate([ + "journal_stage_id"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $url = "http://journalapi.tmrjournals.com/public/index.php/master/Journal/delStage"; + $program['journal_stage_id'] = $data['journal_stage_id']; + $res = object_to_array(json_decode(myPost($url,$program))); + if($res['code']==0){ + return jsonSuccess($res); + }else{ + return jsonError($res['msg']); + } + + } + + public function getJournalStageArticles(){ + $data = $this->request->post(); + $rule = new Validate([ + "journal_id" => "require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $journal_info = $this->journal_obj->where("journal_id",$data['journal_id'])->find(); + $url = "http://journalapi.tmrjournals.com/public/index.php/master/Journal/getJournalStageArticlesForSubmission"; + $program['issn'] = $journal_info['issn']; + $res = object_to_array(json_decode(myPost($url,$program))); + + $list = empty($res['data']['list']) ? [] : $res['data']['list']; + //获取微信公众号文章状态 chengxiaoling 20250522 start + if(!empty($list)){ + $aArticleId = array_column($list, 'article_id'); + $aWechatArticle = $this->getWechatInfo($aArticleId); + $aAiArticle = empty($aWechatArticle['ai_article']) ? [] : $aWechatArticle['ai_article']; + $aAiWechatArticle = empty($aWechatArticle['ai_wechat_article']) ? [] : $aWechatArticle['ai_wechat_article']; + foreach ($list as $key => $val) { + //获取微信公众号文章状态 chengxiaoling 20250522 start + $list[$key]['ai_wechat_status'] = 2; //1 Ai内容已生成 2ai内容未生成 + if(in_array($val['article_id'],$aAiArticle)){ + $list[$key]['ai_wechat_status'] = 1; + //是否推送到微信 + $aDraft = empty($aAiWechatArticle[$val['article_id']]) ? [] : $aAiWechatArticle[$val['article_id']]; + $list[$key]['ai_wechat_status'] = empty($aDraft) ? 3 : 4; //3 未生成草稿 4 已生成草稿未发布 10 发布成功 11 发布中 >11发布失败 + if(!empty($aDraft)){ + foreach ($aDraft as $kk => $value) { + if($kk == '-1'){ + $list[$key]['ai_wechat_status'] = 4; + }else{ + $list[$key]['ai_wechat_status'] = '1'.$kk; + } + } + } + } + //获取微信公众号文章状态 chengxiaoling 20250522 end + } + } + //获取微信公众号文章状态 chengxiaoling 20250522 end + + $re['list'] = $list; + return jsonSuccess($re); + } + + public function pushArticleToPublic(){ + $data = $this->request->post(); + $rule = new Validate([ + "article_id"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + //推送数据到数据库 + $uu = "http://journalapi.tmrjournals.com/public/index.php/master/Datebase/dataPushForLx"; + $program['article_id'] = $data['article_id']; + $res = object_to_array(json_decode(myPost($uu,$program))); + + //更改文章状态 + $url = "http://journalapi.tmrjournals.com/public/index.php/master/Journal/publishArticleForSubmission"; + $program['article_id'] = $data['article_id']; + $res = object_to_array(json_decode(myPost($url,$program))); + return jsonSuccess([]); + } + + + public function editJournalLeftZc(){ + $data = $this->request->post(); + $rule = new Validate([ + "journal_id"=>"require", + "ethics"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $this->journal_obj->where("journal_id",$data['journal_id'])->update(["ethics"=>$data['ethics']]); + return jsonSuccess([]); + } + + /** + * 获取期刊列表 + */ + public function getJournalByeditor() + { + $user_id = $this->request->post('user_id'); + $list = $this->journal_obj->where('editor_id',$user_id)->where("state",0)->select(); + + //接口请求获取journal_topic 和 abstract_chinese chengxiaoling 20250514 start + if(!empty($list)){ + $list = $this->_getJournalForApi($list); + } + //接口请求获取journal_topic 和 abstract_chinese chengxiaoling 20250514 end + + $re['journals'] = $list; + return jsonSuccess($re); + } + + /** + * 获取可申请审稿人的期刊 + */ + public function getJournalsForReviewerInEditor(){ + $data = $this->request->post(); + $rule = new Validate([ + 'editor_id' => 'require', + 'reviewer_id' => 'require' + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + + $editor_info = $this->user_obj->where('user_id',$data['editor_id'])->find(); + $journalIds = []; + if($editor_info['type']==2){//责任编辑 + $journalIds = $this->journal_obj->where('editor_id',$editor_info['user_id'])->column('journal_id'); + }else{//客座编辑 + $guests = $this->user_to_special_obj->where('user_id',$data['reviewer_id'])->where('uts_state',0)->select(); + $usercontroller = new usercontroller(); + foreach($guests as $v){ + $c_res = $usercontroller->getSpecialDetailById($v['special_id']); + $journalIds[] = $this->journal_obj->where('issn',$c_res['journal_issn'])->value('journal_id'); + } + } + $njournalIds = $this->reviewer_to_journal_obj->where('reviewer_id',$data['reviewer_id'])->where('state',0)->column('journal_id'); + $list = $this->journal_obj->where('journal_id',"not in",$njournalIds)->where('journal_id',"in",$journalIds)->where('state',0)->select(); + + $re['journals'] = $list; + return jsonSuccess($re); + } + + public function editJournal(){ + $data = $this->request->post(); + $rule = new Validate([ + 'journal_id'=>'require', + 'level'=>'require', + 'email'=>'require', + 'epassword'=>'require', + "kfen"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $journal_info = $this->journal_obj->where("journal_id",$data['journal_id'])->find(); + $url = "http://journalapi.tmrjournals.com/public/index.php/master/Journal/editJournalEmailPasswordForSubmission"; + $program['issn'] = $journal_info['issn']; + $program['epassword'] = $data['epassword']; + $res = object_to_array(json_decode(myPost($url,$program))); + + $update['level'] = $data['level']; + $update['email'] = $data['email']; + $update['epassword'] = $data['epassword']; + $update['kfen'] = $data['kfen']; + + //新增字段期刊涵盖主题多个逗号分隔\中文简介\发布作者\编辑二维码 chengxiaoling 20250507 start + if(isset($data['journal_topic'])){ + if(is_array($data['journal_topic'])){ + $update['journal_topic'] = implode(',', $data['journal_topic']); + }else{ + $update['journal_topic'] = $data['journal_topic']; + } + $aJournalUpdate['journal_topic'] = $update['journal_topic']; + } + if(isset($data['abstract_chinese'])){ + $update['abstract_chinese'] = $data['abstract_chinese']; + $aJournalUpdate['abstract_chinese'] = $update['abstract_chinese']; + } + if(isset($data['publish_author'])){ + $update['publish_author'] = $data['publish_author']; + $aJournalUpdate['publish_author'] = $update['publish_author']; + } + if(isset($data['editor_qrcode'])){ + $update['editor_qrcode'] = $data['editor_qrcode']; + $aJournalUpdate['editor_qrcode'] = $update['editor_qrcode']; + } + + if(isset($data['wechat_name'])){ + $update['wechat_name'] = $data['wechat_name']; + $aJournalUpdate['wechat_name'] = $update['wechat_name']; + } + if(isset($data['wechat_app_id'])){ + $update['wechat_app_id'] = $data['wechat_app_id']; + $aJournalUpdate['wechat_app_id'] = $update['wechat_app_id']; + } + if(isset($data['wechat_app_secret'])){ + $update['wechat_app_secret'] = $data['wechat_app_secret']; + $aJournalUpdate['wechat_app_secret'] = $update['wechat_app_secret']; + } + if (isset($data['wechat_yboard_qrcode'])){ + $update['wechat_yboard_qrcode'] = $data['wechat_yboard_qrcode']; + } + + if(isset($data['editor_name'])&&$data['editor_name']!=''){ + $update['editor_name'] = $data['editor_name']; + } + + if(isset($data['databases'])&&$data['databases']!=''){ + $update['databases'] = $data['databases']; + } + + if(!empty($aJournalUpdate)){ + $aJournalUpdate['issn'] = $journal_info['issn']; + $sUrl = $this->sJournalUrl."wechat/Article/updateJournal"; + $program['issn'] = $journal_info['issn']; + $res = object_to_array(json_decode(myPost($sUrl,$aJournalUpdate))); + } + //新增字段期刊涵盖主题多个逗号分隔 chengxiaoling 20250507 end + if(isset($data['fee'])&&$data['fee']!=0){ + $update['fee'] = $data['fee']; + } + + //新增字段 收费说明及链接 20260104 start + //收费链接 + if(isset($data['apc_url'])){ + $update['apc_url'] = trim($data['apc_url']); + } + //收费说明 + if(isset($data['apc_content'])){ + $update['apc_content'] = trim($data['apc_content']); + } + //新增字段 收费说明及链接 20260104 end + + $update['scope'] = isset($data['scope'])?trim($data['scope']):""; + $this->journal_obj->where('journal_id',$data['journal_id'])->update($update); + return jsonSuccess([]); + } + + /** + * 获取期刊详情 + */ + public function getJournalDetail(){ + $data = $this->request->post(); + $rule = new Validate([ + 'journal_id'=>'require' + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $info = $this->journal_obj->where('journal_id',$data['journal_id'])->find(); + $re['journal'] = $info; + return jsonSuccess($re); + } + + /** + * 获取期刊详情通过文章id + */ + public function getJournalDetailByArticleId(){ + $data = $this->request->post(); + $rule = new Validate([ + 'article_id'=>'require' + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $article_info = $this->article_obj->where('article_id',$data['article_id'])->find(); + $info = $this->journal_obj->where('journal_id',$article_info['journal_id'])->find(); + $re['journal'] = $info; + return jsonSuccess($re); + + } + + + + /** + * 接口请求获取Journal数据库里的期刊话题及中文简介 + */ + private function _getJournalForApi($list = []){ + if(empty($list)){ + return []; + } + $aIssn = array_column($list, 'issn'); + $sUrl = $this->sJournalUrl."master/Journal/getJournals"; + $aParam['issn'] = $aIssn; + $aResult = object_to_array(json_decode(myPost1($sUrl,$aParam))); + $aData = empty($aResult['data']) ? [] : $aResult['data']; + $aJournal = empty($aData['journals']) ? [] : array_column($aData['journals'],null,'issn'); + foreach ($list as $key => $value) { + $aJournalInfo = empty($aJournal[$value['issn']]) ? [] : $aJournal[$value['issn']]; + + $list[$key]['journal_topic'] = empty($aJournalInfo['journal_topic']) ? '' : $aJournalInfo['journal_topic']; + $list[$key]['abstract_chinese'] = empty($aJournalInfo['abstract_chinese']) ? '' : $aJournalInfo['abstract_chinese']; + $list[$key]['publish_author'] = empty($aJournalInfo['publish_author']) ? '' : $aJournalInfo['publish_author']; + $list[$key]['editor_qrcode'] = empty($aJournalInfo['editor_qrcode']) ? '' : $aJournalInfo['editor_qrcode']; + $list[$key]['wechat_name'] = empty($aJournalInfo['wechat_name']) ? '' : $aJournalInfo['wechat_name']; + $list[$key]['wechat_app_id'] = empty($aJournalInfo['wechat_app_id']) ? '' : $aJournalInfo['wechat_app_id']; + $list[$key]['wechat_app_secret'] = empty($aJournalInfo['wechat_app_secret']) ? '' : $aJournalInfo['wechat_app_secret']; + } + return $list; + } + + /** + * 上传期刊编辑的二维码 + */ + public function uploadEditorQrcode() + { + $file = request()->file('qrcode_url'); + if ($file) { + $info = $file->move(ROOT_PATH . 'public' . DS . 'journaleditorqrcode'); + if ($info) { + return json(['code' => 0, 'upurl' => str_replace("\\", "/", $info->getSaveName())]); + } else { + return json(['code' => 1, 'msg' => $file->getError()]); + } + } + } + + public function uploadYboardQrcode() +{ + $file = request()->file('qrcode_url'); + if ($file) { + $info = $file->move(ROOT_PATH . 'public' . DS . 'journalyboardqrcode'); + if ($info) { + return json(['code' => 0, 'upurl' => str_replace("\\", "/", $info->getSaveName())]); + } else { + return json(['code' => 1, 'msg' => $file->getError()]); + } + } +} + + /** + * 获取微信公众号相关数量 + */ + public function getWechatInfo($aArticleId){ + + if(empty($aArticleId)){ + return []; + } + + //获取文章生成记录 + $aWhere = ['article_id' => ['in',$aArticleId],'is_delete' => 2]; + $aAiArticle = Db::name('ai_article')->where($aWhere)->column('article_id'); + if(!empty($aAiArticle)){ + //获取推送到草稿箱否 + $aWhere['article_id'] = ['in',$aAiArticle]; + $ai_wechat_article = Db::name('ai_wechat_article')->field('article_id,template_id,wechat_id,is_publish,publish_status')->where($aWhere)->select(); + if(!empty($ai_wechat_article)){ + foreach ($ai_wechat_article as $key => $value) { + $aWechatArticle[$value['article_id']][$value['publish_status']][] = $value['template_id']; + } + } + } + unset($ai_wechat_article); + //返回数据 + return ['ai_article' => $aAiArticle,'ai_wechat_article' => empty($aWechatArticle) ? [] : $aWechatArticle]; + } + +} diff --git a/application/common/ProductionArticleRefer.php b/application/common/ProductionArticleRefer.php index b61b42d1..51f53d0c 100644 --- a/application/common/ProductionArticleRefer.php +++ b/application/common/ProductionArticleRefer.php @@ -106,6 +106,21 @@ class ProductionArticleRefer $update_a['cs'] = 1; $update_a['update_time'] = time(); $update_a['is_deal'] = 1; + + try { + (new ReferenceReferAuthorService())->syncFromWorkSummary( + $iPReferId, + $iPArticleId, + $doiNorm, + $summary + ); + } catch (\Throwable $e) { + \think\Log::error( + 'ProductionArticleRefer sync refer authors failed p_refer_id=' + . $iPReferId . ' ' . $e->getMessage() + ); + } + Db::name('production_article_refer')->where(['p_refer_id' => $iPReferId])->limit(1)->update($update_a); return json_encode(['status' => 1,'msg' => 'Update successful']); } diff --git a/application/common/ProductionArticleReferLiteratureService.php b/application/common/ProductionArticleReferLiteratureService.php new file mode 100644 index 00000000..89a45ab6 --- /dev/null +++ b/application/common/ProductionArticleReferLiteratureService.php @@ -0,0 +1,171 @@ +where('p_refer_id', $pReferId); + if (intval($pArticleId) > 0) { + $q->where('p_article_id', intval($pArticleId)); + } + $row = $q->find(); + + return is_array($row) ? $row : null; + } + + /** + * 从 t_production_article_refer_literature 读取校对用文献字段(不回落 refer 主表) + * + * @return array{ + * abstract_text:string, + * content_text:string, + * mesh_terms:string, + * refer_content_cleaned:string, + * literature_pdf_url:string, + * fetch_sources:string, + * fetch_log:string, + * refer_doi:string + * } + */ + public function loadForCheck($pReferId, $pArticleId = 0) + { + $empty = [ + 'abstract_text' => '', + 'content_text' => '', + 'mesh_terms' => '', + 'refer_content_cleaned' => '', + 'literature_pdf_url' => '', + 'fetch_sources' => '', + 'fetch_log' => '', + 'refer_doi' => '', + ]; + $stored = $this->getByPReferId($pReferId, $pArticleId); + if (empty($stored)) { + return $empty; + } + + return [ + 'abstract_text' => trim((string)($stored['abstract_text'] ?? '')), + 'content_text' => trim((string)($stored['content_text'] ?? '')), + 'mesh_terms' => trim((string)($stored['mesh_terms'] ?? '')), + 'refer_content_cleaned' => trim((string)($stored['refer_content_cleaned'] ?? '')), + 'literature_pdf_url' => trim((string)($stored['literature_pdf_url'] ?? '')), + 'fetch_sources' => trim((string)($stored['fetch_sources'] ?? '')), + 'fetch_log' => trim((string)($stored['fetch_log'] ?? '')), + 'refer_doi' => trim((string)($stored['refer_doi'] ?? '')), + ]; + } + + /** + * @deprecated 使用 loadForCheck;保留兼容,仅读下属表 + */ + public function resolveLiteratureFields(array $refer, $pArticleId = 0) + { + $pReferId = intval($refer['p_refer_id'] ?? 0); + $lit = $this->loadForCheck($pReferId, $pArticleId); + + return [ + 'abstract_text' => $lit['abstract_text'], + 'content_text' => $lit['content_text'], + 'mesh_terms' => $lit['mesh_terms'], + 'refer_content_cleaned' => $lit['refer_content_cleaned'], + 'literature_pdf_url' => $lit['literature_pdf_url'], + ]; + } + + /** + * 写入/更新文献内容 + * + * @param array $data abstract_text, content_text, mesh_terms, refer_content_cleaned, literature_pdf_url, refer_doi, fetch_sources, fetch_log + */ + public function upsert($pArticleId, $pReferId, array $data) + { + $pArticleId = intval($pArticleId); + $pReferId = intval($pReferId); + if ($pReferId <= 0) { + return 0; + } + + $now = date('Y-m-d H:i:s'); + $row = [ + 'p_article_id' => $pArticleId, + 'p_refer_id' => $pReferId, + 'refer_doi' => $this->clip((string)($data['refer_doi'] ?? ''), 128), + 'abstract_text' => (string)($data['abstract_text'] ?? ''), + 'content_text' => (string)($data['content_text'] ?? ''), + 'mesh_terms' => $this->formatMeshTerms($data['mesh_terms'] ?? ''), + 'refer_content_cleaned' => (string)($data['refer_content_cleaned'] ?? ''), + 'literature_pdf_url' => $this->clip((string)($data['literature_pdf_url'] ?? ''), 1024), + 'fetch_sources' => $this->clip($this->formatSources($data['fetch_sources'] ?? ''), 255), + 'fetch_log' => $this->clip((string)($data['fetch_log'] ?? ''), 512), + 'updated_at' => $now, + ]; + + $existing = Db::name('production_article_refer_literature')->where('p_refer_id', $pReferId)->find(); + if (!empty($existing)) { + Db::name('production_article_refer_literature') + ->where('p_refer_id', $pReferId) + ->update($row); + + return intval($existing['id']); + } + + $row['created_at'] = $now; + + return intval(Db::name('production_article_refer_literature')->insertGetId($row)); + } + + public function formatMeshTerms($mesh) + { + if (is_array($mesh)) { + $parts = []; + foreach ($mesh as $term) { + $term = trim((string)$term); + if ($term !== '') { + $parts[$term] = $term; + } + } + + return implode('; ', array_values($parts)); + } + + return trim((string)$mesh); + } + + private function formatSources($sources) + { + if (is_array($sources)) { + return implode(',', array_values(array_unique(array_filter(array_map('strval', $sources))))); + } + + return trim((string)$sources); + } + + private function clip($text, $max) + { + $text = trim((string)$text); + if ($text === '' || $max <= 0) { + return ''; + } + if (mb_strlen($text) <= $max) { + return $text; + } + + return mb_substr($text, 0, $max); + } +} diff --git a/application/common/ReferenceAuthorIdentityService.php b/application/common/ReferenceAuthorIdentityService.php new file mode 100644 index 00000000..8b35eb8b --- /dev/null +++ b/application/common/ReferenceAuthorIdentityService.php @@ -0,0 +1,283 @@ +bgCheck = new BackgroundCheckService(); + $this->refUtil = new ReferenceCheckService(); + $this->crossref = new CrossrefService([ + 'mailto' => trim((string)\think\Env::get('crossref_mailto', '')), + ]); + } + + /** + * 本文作者身份(精准统计仅认 ORCID → OpenAlex / ORCID 键) + * + * @return array + */ + public function resolveManuscriptAuthors($pArticleId) + { + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + return []; + } + + $rows = Db::name('production_article_author') + ->field('p_article_author_id,first_name,last_name,author_name,orcid') + ->where('p_article_id', $pArticleId) + ->where('state', 0) + ->select(); + + $identities = []; + foreach ($rows as $row) { + $identities[] = $this->resolveOneManuscriptAuthor($row); + } + + return $identities; + } + + /** + * 单条参考文献的作者身份(优先 OpenAlex work by DOI,其次 Crossref ORCID) + * + * @return array{ + * authorships:array, + * first_author:array|null, + * match_confidence:string, + * identity_source:string + * } + */ + public function resolveReferAuthorships(array $refer, array &$workCache, array &$crossrefCache) + { + $pReferId = intval($refer['p_refer_id'] ?? 0); + if ($pReferId > 0) { + $stored = (new ReferenceReferAuthorService())->loadAuthorshipsByPReferId($pReferId); + if (!empty($stored)) { + return [ + 'authorships' => $stored, + 'first_author' => $this->pickFirstAuthorship($stored), + 'match_confidence' => self::MATCH_PRECISE, + 'identity_source' => 'stored', + ]; + } + } + + $doi = $this->refUtil->extractDoiFromRefer($refer); + if ($doi === '') { + return [ + 'authorships' => [], + 'first_author' => null, + 'match_confidence' => self::MATCH_NONE, + 'identity_source' => '', + ]; + } + + if (!array_key_exists($doi, $workCache)) { + $workCache[$doi] = $this->fetchOpenAlexAuthorships($doi); + usleep(80000); + } + + $authorships = $workCache[$doi]; + $source = 'openalex'; + + if (empty($authorships)) { + if (!array_key_exists($doi, $crossrefCache)) { + $crossrefCache[$doi] = $this->fetchCrossrefAuthorships($doi); + usleep(80000); + } + $authorships = $crossrefCache[$doi]; + $source = 'crossref_orcid'; + } + + $firstAuthor = $this->pickFirstAuthorship($authorships); + $confidence = empty($authorships) ? self::MATCH_NONE : self::MATCH_PRECISE; + + return [ + 'authorships' => $authorships, + 'first_author' => $firstAuthor, + 'match_confidence' => $confidence, + 'identity_source' => $source, + ]; + } + + /** + * @param array $manuscriptAuthors + * @param array $referAuthorships + */ + public function matchManuscriptToRefer(array $manuscriptAuthors, array $referAuthorships) + { + $manuscriptKeys = []; + foreach ($manuscriptAuthors as $author) { + if (($author['match_confidence'] ?? '') !== self::MATCH_PRECISE) { + continue; + } + foreach ((array)($author['identity_keys'] ?? []) as $key) { + $manuscriptKeys[$key] = $author; + } + } + + foreach ($referAuthorships as $auth) { + foreach ((array)($auth['identity_keys'] ?? []) as $key) { + if (isset($manuscriptKeys[$key])) { + return $manuscriptKeys[$key]; + } + } + } + + return null; + } + + public function buildFuzzyAuthorKey($authorCitationPart) + { + $part = trim(preg_replace('/\.+$/u', '', trim((string)$authorCitationPart))); + if ($part === '' || preg_match('/^et\s+al\.?$/iu', $part)) { + return ''; + } + + $tokens = preg_split('/\s+/u', $part, -1, PREG_SPLIT_NO_EMPTY); + if (count($tokens) === 1) { + return 'fuzzy:' . mb_strtoupper($tokens[0]) . '|'; + } + + $last = array_pop($tokens); + if (preg_match('/^[A-Za-z]{1,4}$/u', $last)) { + $family = implode(' ', $tokens); + return 'fuzzy:' . mb_strtoupper(preg_replace('/\s+/u', ' ', trim($family))) . '|' . mb_strtoupper($last); + } + + $family = $last; + $initials = ''; + foreach ($tokens as $token) { + $initials .= mb_strtoupper(mb_substr($token, 0, 1)); + } + return 'fuzzy:' . mb_strtoupper($family) . '|' . $initials; + } + + public function extractFuzzyFirstAuthorKeyFromRefer(array $refer, array $meta) + { + $author = trim(trim((string)($meta['author'] ?? $refer['author'] ?? '')), '.'); + if ($author === '') { + return ''; + } + $parts = preg_split('/,\s*/u', $author); + $first = trim((string)($parts[0] ?? '')); + return $this->buildFuzzyAuthorKey($first); + } + + private function resolveOneManuscriptAuthor(array $row) + { + $first = trim((string)($row['first_name'] ?? '')); + $last = trim((string)($row['last_name'] ?? '')); + $displayName = ($first !== '' && $last !== '') ? trim($first . ' ' . $last) : trim((string)($row['author_name'] ?? '')); + $orcid = $this->bgCheck->cleanOrcid($row['orcid'] ?? ''); + $openalexId = ''; + $identityKeys = []; + $confidence = self::MATCH_NONE; + $fuzzyKey = ''; + + if ($last !== '') { + $initials = $this->initialsFromGiven($first); + $fuzzyKey = 'fuzzy:' . mb_strtoupper($last) . '|' . $initials; + } + + if ($orcid !== '') { + $identityKeys[] = 'orcid:' . $orcid; + $confidence = self::MATCH_PRECISE; + $res = $this->bgCheck->resolveAuthor(['orcid' => $orcid]); + if (!empty($res['success']) && !empty($res['data'])) { + $openalexId = $this->bgCheck->extractOpenAlexId($res['data']['id'] ?? ''); + if ($openalexId !== '') { + $identityKeys[] = 'openalex:' . $openalexId; + } + if (trim((string)($res['data']['display_name'] ?? '')) !== '') { + $displayName = trim((string)$res['data']['display_name']); + } + } + } + + return [ + 'p_article_author_id' => intval($row['p_article_author_id']), + 'display_name' => $displayName, + 'openalex_id' => $openalexId, + 'orcid' => $orcid, + 'fuzzy_key' => $fuzzyKey, + 'identity_keys' => array_values(array_unique($identityKeys)), + 'match_confidence' => $confidence, + ]; + } + + private function initialsFromGiven($given) + { + $given = trim((string)$given); + if ($given === '') { + return ''; + } + $parts = preg_split('/[\s\-\.]+/u', $given, -1, PREG_SPLIT_NO_EMPTY); + $initials = ''; + foreach ($parts as $part) { + $first = mb_substr($part, 0, 1); + if ($first !== '') { + $initials .= mb_strtoupper($first); + } + } + return $initials; + } + + private function fetchOpenAlexAuthorships($doi) + { + $res = $this->bgCheck->fetchOpenAlexWorkByDoi($doi); + if (empty($res['success']) || empty($res['work']) || !is_array($res['work'])) { + return []; + } + return $this->bgCheck->parseWorkAuthorships($res['work']); + } + + private function fetchCrossrefAuthorships($doi) + { + $res = $this->bgCheck->fetchCrossRefWork($doi); + if (empty($res['success']) || empty($res['message'])) { + return []; + } + return $this->bgCheck->authorshipsFromCrossrefAuthors($res['message']['author'] ?? []); + } + + private function pickFirstAuthorship(array $authorships) + { + if (empty($authorships)) { + return null; + } + foreach ($authorships as $auth) { + if (($auth['author_position'] ?? '') === 'first') { + return $auth; + } + } + return reset($authorships) ?: null; + } +} diff --git a/application/common/ReferenceCheckService.php b/application/common/ReferenceCheckService.php index b9ea2586..192335ab 100644 --- a/application/common/ReferenceCheckService.php +++ b/application/common/ReferenceCheckService.php @@ -2345,7 +2345,7 @@ class ReferenceCheckService } $slice = $this->buildCitationContextText($raw, $extendedStart, $textEnd); - $slice = ltrim($slice, ". \t\n\r"); + $slice = ltrim($slice, "., \t\n\r"); if (trim($slice) === '') { return $fallback; } @@ -3894,9 +3894,13 @@ class ReferenceCheckService $hasPriorCiteInParagraph = ($prevTagEnd > $paragraphStart); $sentenceStart = $this->findSentenceStart($content, $tagStart); - // 段内首个引用:整段到标签前;后续引用:取「本句」起点(可早于上一标签),避免只剩 “and external environment” 再误用标签后文本 + // 段内首个引用:整段到标签前;后续引用:不早于上一标签结束,并可向前扩展若干句覆盖紧邻 claim if ($hasPriorCiteInParagraph) { - $localStart = max($paragraphStart, $sentenceStart); + $anchor = max($prevTagEnd, $sentenceStart); + $localStart = $this->extendContextStartBackward($content, $anchor, $prevTagEnd, 2); + if ($localStart >= $prevTagEnd) { + $localStart = $this->advancePastPriorCitationBoundary($content, $localStart); + } } else { $localStart = $this->capContextStartBeforeTag($content, $tagStart, $paragraphStart); } @@ -3904,19 +3908,34 @@ class ReferenceCheckService // 默认:引用标签前的论述 $localEnd = $tagStart; $originalText = $this->buildCitationContextText($content, $localStart, $localEnd); + $before = $originalText; + $isAuthorOnly = $this->isAuthorOnlyLeadIn($before); - // 仅段内首个引用、且标签前极短(如句末 ICU nurses [14])时,才改用标签后片段;同段多引禁止标签后截取(会错取下一句) - $allowTrailing = !$hasPriorCiteInParagraph; - if ($allowTrailing && ( - !$this->isMeaningfulCitationContext($originalText) - || $this->shouldUseTrailingCitationContext($content, $localStart, $tagStart, $tagEnd) - )) { + // 作者缩写引用(Chen [33]、Zou [24] found that...):向前扩展到段落/句群,必要时并入标签后叙述 + if ($isAuthorOnly) { + $localStart = $this->capContextStartBeforeTag($content, $tagStart, $paragraphStart); $trailEnd = ($nextTagStart < $sentenceEnd) ? $nextTagStart : $sentenceEnd; $trailText = $this->buildCitationContextText($content, $tagEnd, $trailEnd); if ($this->isMeaningfulCitationContext($trailText)) { - $localStart = $tagEnd; $localEnd = $trailEnd; - $originalText = $trailText; + } else { + $localEnd = $tagStart; + } + $originalText = $this->buildCitationContextText($content, $localStart, $localEnd); + } else { + // 仅段内首个引用、且标签前极短时,才改用标签后片段;同段多引禁止(会错取下一句) + $allowTrailing = !$hasPriorCiteInParagraph; + if ($allowTrailing && ( + !$this->isMeaningfulCitationContext($originalText) + || $this->shouldUseTrailingCitationContext($content, $localStart, $tagStart, $tagEnd) + )) { + $trailEnd = ($nextTagStart < $sentenceEnd) ? $nextTagStart : $sentenceEnd; + $trailText = $this->buildCitationContextText($content, $tagEnd, $trailEnd); + if ($this->isMeaningfulCitationContext($trailText)) { + $localStart = $tagEnd; + $localEnd = $trailEnd; + $originalText = $trailText; + } } } @@ -3934,6 +3953,22 @@ class ReferenceCheckService return [$localStart, $localEnd, $originalText]; } + /** + * 标签前仅有作者姓氏/缩写(如 Chen、Zou)时视为作者引导引用,需扩展上下文。 + */ + private function isAuthorOnlyLeadIn($text) + { + $text = trim((string)$text); + if ($text === '') { + return false; + } + if (mb_strlen($text) < 25) { + return true; + } + + return preg_match('/^[A-Z][a-zA-Z\'\-]{0,24}\.?$/u', $text) === 1; + } + /** * 标签前仅有作者缩写等极短片段时,改用标签后上下文 */ @@ -4097,6 +4132,27 @@ class ReferenceCheckService return $start; } + /** + * 上一引用标签结束后,跳过空白及紧随其后的句末/分句标点(如 "[1]. And"、"[2], accounting") + */ + private function advancePastPriorCitationBoundary($content, $pos) + { + $pos = intval($pos); + $len = strlen($content); + while ($pos < $len) { + while ($pos < $len && ($content[$pos] === ' ' || $content[$pos] === "\t" || $content[$pos] === "\n" || $content[$pos] === "\r")) { + $pos++; + } + if ($pos < $len && ($content[$pos] === '.' || $content[$pos] === ',')) { + $pos++; + continue; + } + break; + } + + return $pos; + } + /** * 过滤仅标点、过短或无字母/汉字的上下文(如去掉标签后只剩 ".") */ diff --git a/application/common/ReferenceLiteratureFetchService.php b/application/common/ReferenceLiteratureFetchService.php new file mode 100644 index 00000000..1cdc1d49 --- /dev/null +++ b/application/common/ReferenceLiteratureFetchService.php @@ -0,0 +1,622 @@ +epmc = new EuropePmcService(); + $this->pubmed = new PubmedService([ + 'email' => trim((string)Env::get('pubmed_email', '')), + 'tool' => trim((string)Env::get('pubmed_tool', 'tmrjournals')), + ]); + $this->crossref = new CrossrefService([ + 'mailto' => trim((string)Env::get('crossref_mailto', '')), + ]); + $this->unpaywall = new UnpaywallService(); + $this->cmaJournal = new CmaJournalLiteratureService(); + $this->refUtil = new ReferenceCheckService(); + } + + public function setSkipYiigle($skip = true) + { + $this->skipYiigle = (bool)$skip; + + return $this; + } + + /** + * @return array{ + * doi:string,pmid:string,pmcid:string,title:string,journal:string,year:string, + * abstract:string,raw_content:string,pdf_url:string,mesh_terms:array,sources:array,fetch_log:string + * } + */ + public function fetchForRefer(array $refer) + { + DbReconnectHelper::release(); + + // 图书/ISBN 类参考文献不走 DOI 管道(书上的 DOI 常为错误挂接的期刊文章) + if ($this->shouldSkipDoiFetchForRefer($refer)) { + return $this->emptyResult('book_skip_doi_fetch'); + } + + $dois = $this->resolveDoiCandidatesForFetch($refer); + foreach ($dois as $doi) { + $result = $this->fetchByDoiPipeline($doi, $refer); + if ($this->fetchedResultMatchesRefer($result, $refer)) { + return $result; + } + } + + $title = trim((string)($refer['title'] ?? '')); + $author = trim((string)($refer['author'] ?? '')); + $year = $this->extractYearFromRefer($refer); + if ($title === '') { + $title = $this->guessTitleFromReferContent($refer); + } + + $resolvedDoi = ''; + $meta = null; + if ($title !== '') { + $meta = $this->epmc->searchByBibliographic($title, $author, $year); + if (is_array($meta) && trim((string)($meta['doi'] ?? '')) !== '') { + $resolvedDoi = trim((string)$meta['doi']); + } + if ($resolvedDoi === '') { + $pub = $this->pubmed->searchByBibliographic($title, $author, $year); + if (is_array($pub)) { + $resolvedDoi = trim((string)($pub['doi'] ?? '')); + if ($meta === null) { + $meta = $pub; + } + } + } + } + + if ($resolvedDoi !== '') { + $result = $this->fetchByDoiPipeline($resolvedDoi, $refer); + if (is_array($meta)) { + if ($result['title'] === '' && trim((string)($meta['title'] ?? '')) !== '') { + $result['title'] = trim((string)$meta['title']); + } + } + if ($this->fetchedResultMatchesRefer($result, $refer)) { + $result['fetch_log'] = 'no_doi_in_refer; resolved_doi=' . $resolvedDoi . '; ' . $result['fetch_log']; + return $result; + } + } + + return $this->emptyResult('no_doi_and_bibliographic_search_failed'); + } + + /** + * 判断已入库/已清洗内容与 refer 元数据是否明显错配(用于跳过错误缓存、触发重抓)。 + */ + public function storedContentMatchesRefer(array $refer, $abstract, $cleaned) + { + $text = trim((string)$abstract . "\n" . (string)$cleaned); + if ($text === '') { + return true; + } + + // 子表内容已按 p_refer_id 绑定;用 refer 标题锚定,避免中文摘要因不含英文作者姓氏被误判为错配 + $expectedTitle = trim((string)($refer['title'] ?? '')); + + return $this->fetchedResultMatchesRefer([ + 'title' => $expectedTitle, + 'abstract' => $text, + 'raw_content' => $text, + ], $refer); + } + + /** + * 图书类参考文献:refer_type=book 或带 ISBN 且呈教材/专著特征时,不通过 DOI 抓外部摘要。 + */ + private function shouldSkipDoiFetchForRefer(array $refer) + { + if (strtolower(trim((string)($refer['refer_type'] ?? ''))) === 'book') { + return true; + } + $isbn = trim((string)($refer['isbn'] ?? '')); + if ($isbn === '') { + return false; + } + $blob = strtolower( + trim((string)($refer['joura'] ?? '')) . ' ' + . trim((string)($refer['dateno'] ?? '')) . ' ' + . trim((string)($refer['title'] ?? '')) + ); + + return preg_match('/\bed\.?|edition|publishing|press|lippincott|elsevier|springer|wiley|company|图书|教材|专著/u', $blob); + } + + /** + * 相关性校对抓取:优先 refer_doi/doilink(结构化字段),再 refer_content(原始文本,可能错链)。 + * + * @return string[] + */ + private function resolveDoiCandidatesForFetch(array $refer) + { + $result = []; + foreach (['refer_doi', 'doilink', 'doi', 'refer_content', 'refer_frag'] as $field) { + $slice = array_merge($refer, ['refer_content' => (string)($refer[$field] ?? '')]); + foreach ($this->refUtil->extractAllDoiCandidatesFromRefer($slice) as $doi) { + if (!in_array($doi, $result, true)) { + $result[] = $doi; + } + } + } + + return $result; + } + + /** + * 校验抓取结果标题/作者是否与 refer 行一致,防止 refer_content 错链到另一篇文献。 + */ + private function fetchedResultMatchesRefer(array $result, array $refer) + { + $expectedTitle = trim((string)($refer['title'] ?? '')); + $fetchedTitle = trim((string)($result['title'] ?? '')); + $blob = strtolower( + $fetchedTitle . ' ' + . trim((string)($result['abstract'] ?? '')) . ' ' + . trim((string)($result['raw_content'] ?? '')) + ); + + $titleConfirmed = false; + if ($expectedTitle !== '' && $fetchedTitle !== '') { + if (!$this->titlesLikelyMatch($expectedTitle, $fetchedTitle)) { + return false; + } + $titleConfirmed = true; + } + + $author = trim((string)($refer['author'] ?? '')); + // 标题已能确认同一文献时,不再要求摘要/正文里出现作者姓氏(PubMed 摘要通常不含作者) + if ($author !== '' && !$titleConfirmed) { + $needles = $this->extractAuthorNeedles($author); + $matched = 0; + foreach ($needles as $needle) { + if ($needle !== '' && strpos($blob, $needle) !== false) { + $matched++; + } + } + if (!empty($needles) && $matched === 0) { + return false; + } + } + + return true; + } + + private function titlesLikelyMatch($expected, $fetched) + { + $a = $this->normalizeTitleForMatch($expected); + $b = $this->normalizeTitleForMatch($fetched); + if ($a === '' || $b === '') { + return true; + } + if ($a === $b || strpos($a, $b) !== false || strpos($b, $a) !== false) { + return true; + } + similar_text($a, $b, $pct); + + return $pct >= 38; + } + + private function normalizeTitleForMatch($title) + { + $title = strtolower(trim((string)$title)); + $title = preg_replace('/[^a-z0-9\s]+/u', ' ', $title); + + return trim(preg_replace('/\s+/u', ' ', $title)); + } + + /** + * @return string[] + */ + private function extractAuthorNeedles($author) + { + $author = trim((string)$author); + if ($author === '') { + return []; + } + $needles = []; + if (preg_match_all('/[A-Za-z]{3,}/', $author, $m)) { + foreach ($m[0] as $part) { + $needles[] = strtolower($part); + } + } + + return array_values(array_unique($needles)); + } + + /** + * 抓取 + LLM 清洗(校对执行时调用) + * + * @return array 含 abstract_final, content_cleaned + */ + public function fetchAndCleanForRefer(array $refer) + { + $fetched = $this->fetchForRefer($refer); + DbReconnectHelper::ensure(); + + $raw = trim((string)($fetched['raw_content'] ?? '')); + if ($raw === '') { + return array_merge($fetched, [ + 'abstract_final' => trim((string)($fetched['abstract'] ?? '')), + 'content_cleaned' => '', + 'content_clean_skip'=> true, + ]); + } + + DbReconnectHelper::release(); + $fetchedForClean = $fetched; + $clean = (new \app\common\service\ReferenceContentCleanLlmService())->clean($raw, $fetchedForClean); + DbReconnectHelper::ensure(); + + $abstractFinal = trim((string)($clean['abstract'] ?? '')); + if ($abstractFinal === '') { + $abstractFinal = trim((string)($fetched['abstract'] ?? '')); + } + + return array_merge($fetched, [ + 'abstract_final' => $abstractFinal, + 'content_cleaned' => trim((string)($clean['cleaned'] ?? '')), + 'content_clean_skip' => !empty($clean['skipped']), + ]); + } + + private function fetchByDoiPipeline($doi, array $refer) + { + $doi = trim((string)$doi); + $blocks = []; + $sources = []; + $abstract = ''; + $title = trim((string)($refer['title'] ?? '')); + $pmid = ''; + $pmcid = ''; + $journal = ''; + $year = $this->extractYearFromRefer($refer); + $fetchLogs = []; + $pdfUrl = ''; + $meshTerms = []; + + // 0) 中华医学会期刊 DOI:OpenAlex 中文摘要(可选 Yiigle 机构 API) + if (CmaJournalLiteratureService::isCmaJournalDoi($doi)) { + $cmaSvc = $this->cmaJournal; + if ($this->skipYiigle) { + $cmaSvc = new CmaJournalLiteratureService(['skip_yiigle' => true]); + } + $cma = $cmaSvc->fetchByDoi($doi); + if (is_array($cma)) { + $sources = array_merge($sources, (array)($cma['sources'] ?? [])); + if ($title === '' && trim((string)($cma['title'] ?? '')) !== '') { + $title = trim((string)$cma['title']); + } + if ($journal === '' && trim((string)($cma['journal'] ?? '')) !== '') { + $journal = trim((string)$cma['journal']); + } + if ($year === '' && trim((string)($cma['year'] ?? '')) !== '') { + $year = trim((string)$cma['year']); + } + if ($pmid === '' && trim((string)($cma['pmid'] ?? '')) !== '') { + $pmid = trim((string)$cma['pmid']); + } + if (trim((string)($cma['abstract'] ?? '')) !== '') { + $abstract = trim((string)$cma['abstract']); + } + foreach ((array)($cma['blocks'] ?? []) as $block) { + $block = trim((string)$block); + if ($block !== '') { + $blocks[] = $block; + } + } + $cmaContent = trim((string)($cma['content'] ?? '')); + if ($cmaContent !== '') { + $blocks[] = "=== 中华医学期刊全文 ===\n" . $this->truncate($cmaContent, 20000); + } + if (trim((string)($cma['fetch_log'] ?? '')) !== '') { + $fetchLogs[] = (string)$cma['fetch_log']; + } + } + } + + // 1) Europe PMC by DOI + $epmc = $this->epmc->searchByDoi($doi); + if (is_array($epmc)) { + $sources[] = 'europe_pmc'; + if ($title === '') { + $title = trim((string)($epmc['title'] ?? '')); + } + if (trim((string)($epmc['abstract'] ?? '')) !== '') { + $abstract = trim((string)$epmc['abstract']); + $blocks[] = "=== Europe PMC ===\n" . $abstract; + } + $pmid = trim((string)($epmc['pmid'] ?? '')); + $pmcid = trim((string)($epmc['pmcid'] ?? '')); + $journal = trim((string)($epmc['journal'] ?? '')); + if ($year === '' && trim((string)($epmc['year'] ?? '')) !== '') { + $year = trim((string)$epmc['year']); + } + } + + // 2) PubMed metadata + $pub = $this->pubmed->fetchByDoi($doi); + if (is_array($pub)) { + $sources[] = 'pubmed'; + if ($pmid === '' && trim((string)($pub['pmid'] ?? '')) !== '') { + $pmid = trim((string)$pub['pmid']); + } + if ($title === '' && trim((string)($pub['title'] ?? '')) !== '') { + $title = trim((string)$pub['title']); + } + if ($abstract === '' && trim((string)($pub['abstract'] ?? '')) !== '') { + $abstract = trim((string)$pub['abstract']); + } + if (!empty($pub['mesh_terms']) && is_array($pub['mesh_terms'])) { + $meshTerms = array_values(array_unique(array_merge($meshTerms, $pub['mesh_terms']))); + } + $pubBlock = $this->formatPubmedBlock($pub, $doi); + if ($pubBlock !== '' && $abstract === '') { + $blocks[] = $pubBlock; + } elseif ($pubBlock !== '' && !CmaJournalLiteratureService::isCmaJournalDoi($doi)) { + $blocks[] = $pubBlock; + } + } + + // 3) PMC full text + if ($pmcid !== '') { + $full = $this->epmc->fetchFullTextByPmcid($pmcid); + if ($full !== '') { + $sources[] = 'pmc_fulltext'; + $blocks[] = "=== PMC Full Text ({$pmcid}) ===\n" . $this->truncate($full, 20000); + } + } + + // 4) Unpaywall OA PDF + PDF parse + $oaPdfUrl = $this->unpaywall->findOaPdfUrl($doi); + if ($oaPdfUrl !== '') { + $pdfUrl = $oaPdfUrl; + $pdfText = $this->downloadAndExtractPdf($oaPdfUrl); + if ($pdfText !== '') { + $sources[] = 'unpaywall_pdf'; + $blocks[] = "=== OA PDF Extract ===\nSource: {$oaPdfUrl}\n" . $this->truncate($pdfText, 25000); + } + } + + if ($pdfUrl === '' && $pmcid !== '') { + $pdfUrl = $this->buildPmcPdfUrl($pmcid); + } + + // 5) Crossref supplement(已有实质性摘要或全文时跳过,避免与 PubMed 等重复) + if (!$this->shouldSkipCrossrefSupplement($abstract, $sources, $blocks)) { + $cr = $this->refUtil->fetchCrossrefAbstractByReferDoi(['refer_doi' => $doi, 'doi' => $doi]); + if (is_array($cr) && trim((string)($cr['text'] ?? '')) !== '') { + $sources[] = 'crossref'; + $blocks[] = trim((string)$cr['text']); + if ($abstract === '' && !empty($cr['has_abstract'])) { + if (preg_match('/Abstract:\s*(.+)/uis', (string)$cr['text'], $m)) { + $abstract = trim($m[1]); + } + } + } + } else { + $fetchLogs[] = 'crossref=skipped_has_abstract_or_fulltext'; + } + + $raw = trim(implode("\n\n", array_filter($blocks))); + if ($raw === '' && $abstract !== '') { + $raw = $abstract; + } + + return [ + 'doi' => $doi, + 'pmid' => $pmid, + 'pmcid' => $pmcid, + 'title' => $title, + 'journal' => $journal, + 'year' => $year, + 'abstract' => $abstract, + 'raw_content' => $raw, + 'pdf_url' => $pdfUrl, + 'mesh_terms' => $meshTerms, + 'sources' => array_values(array_unique($sources)), + 'fetch_log' => trim('doi=' . $doi . '; sources=' . implode(',', $sources) . ($fetchLogs ? '; ' . implode('; ', $fetchLogs) : '')), + ]; + } + + private function shouldSkipCrossrefSupplement($abstract, array $sources, array $blocks) + { + if (mb_strlen(trim((string)$abstract)) >= 40) { + return true; + } + if (!empty(array_intersect($sources, ['pmc_fulltext', 'unpaywall_pdf', 'cma_yiigle']))) { + return true; + } + foreach ($blocks as $block) { + if (preg_match('/===\s*(PMC Full Text|OA PDF Extract|中华医学期刊全文)/u', (string)$block)) { + return true; + } + } + + return false; + } + + private function buildPmcPdfUrl($pmcid) + { + $pmcid = strtoupper(trim((string)$pmcid)); + if ($pmcid === '') { + return ''; + } + if (strpos($pmcid, 'PMC') !== 0) { + $pmcid = 'PMC' . preg_replace('/\D/', '', $pmcid); + } + + return 'https://pmc.ncbi.nlm.nih.gov/articles/' . rawurlencode($pmcid) . '/pdf/'; + } + + private function formatPubmedBlock(array $pub, $doi) + { + $lines = ['=== PubMed (DOI ' . $doi . ') ===']; + foreach (['title', 'journal', 'year'] as $k) { + if (!empty($pub[$k])) { + $lines[] = ucfirst($k) . ': ' . trim((string)$pub[$k]); + } + } + if (!empty($pub['publication_types'])) { + $lines[] = 'Publication Types: ' . implode('; ', (array)$pub['publication_types']); + } + if (!empty($pub['mesh_terms'])) { + $lines[] = 'MeSH: ' . implode('; ', (array)$pub['mesh_terms']); + } + if (!empty($pub['abstract'])) { + $lines[] = 'Abstract: ' . trim((string)$pub['abstract']); + } + return implode("\n", $lines); + } + + private function downloadAndExtractPdf($url) + { + $url = trim((string)$url); + if ($url === '') { + return ''; + } + + $dir = ROOT_PATH . 'runtime' . DS . 'ref_literature_pdf'; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + $path = $dir . DS . date('YmdHis') . '_' . uniqid('', true) . '.pdf'; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 15, + CURLOPT_TIMEOUT => 90, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_HTTPHEADER => ['User-Agent: TMRjournals-RefFetch/1.0'], + ]); + $body = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($body === false || $code < 200 || $code >= 300 || strlen($body) < 1000) { + return ''; + } + if (strlen($body) > 15 * 1024 * 1024) { + return ''; + } + if (@file_put_contents($path, $body) === false) { + return ''; + } + + try { + $text = $this->extractPdfText($path); + } finally { + @unlink($path); + } + + return $text; + } + + private function extractPdfText($path) + { + if (!class_exists(PdfParser::class)) { + return $this->extractPdfTextByPython($path); + } + try { + $parser = new PdfParser(); + $pdf = $parser->parseFile($path); + $text = $pdf->getText(); + return is_string($text) ? trim($text) : ''; + } catch (\Throwable $e) { + return $this->extractPdfTextByPython($path); + } + } + + private function extractPdfTextByPython($path) + { + $script = ROOT_PATH . 'scripts' . DS . 'extract_pdf_text.py'; + if (!is_file($script)) { + return ''; + } + $cmd = 'python ' . escapeshellarg($script) . ' ' . escapeshellarg($path) . ' 2>nul'; + $out = shell_exec($cmd); + return is_string($out) ? trim($out) : ''; + } + + private function extractYearFromRefer(array $refer) + { + $dateno = trim((string)($refer['dateno'] ?? '')); + if (preg_match('/(19|20)\d{2}/', $dateno, $m)) { + return $m[0]; + } + return ''; + } + + private function guessTitleFromReferContent(array $refer) + { + $content = trim((string)($refer['refer_content'] ?? '')); + if ($content === '') { + return ''; + } + $line = preg_split('/\n/', $content)[0] ?? $content; + $line = preg_replace('/^\[\d+\]\s*/', '', trim($line)); + return mb_substr($line, 0, 300); + } + + private function truncate($text, $max) + { + $text = trim((string)$text); + if ($text === '') { + return ''; + } + if (mb_strlen($text) <= $max) { + return $text; + } + return mb_substr($text, 0, $max) . "\n...(truncated)"; + } + + private function emptyResult($reason) + { + return [ + 'doi' => '', + 'pmid' => '', + 'pmcid' => '', + 'title' => '', + 'journal' => '', + 'year' => '', + 'abstract' => '', + 'raw_content' => '', + 'pdf_url' => '', + 'mesh_terms' => [], + 'sources' => [], + 'fetch_log' => (string)$reason, + ]; + } +} diff --git a/application/common/ReferenceReferAuthorService.php b/application/common/ReferenceReferAuthorService.php new file mode 100644 index 00000000..0303b7d6 --- /dev/null +++ b/application/common/ReferenceReferAuthorService.php @@ -0,0 +1,520 @@ +bgCheck = new BackgroundCheckService(); + $this->crossref = new CrossrefService([ + 'mailto' => trim((string)\think\Env::get('crossref_mailto', '')), + ]); + } + + /** + * Crossref enrichment 成功后:解析并覆盖写入作者明细 + * + * @return int 写入作者条数 + */ + public function syncFromWorkSummary($pReferId, $pArticleId, $doi, array $summary) + { + $pReferId = intval($pReferId); + $pArticleId = intval($pArticleId); + if ($pReferId <= 0) { + return 0; + } + + $rows = $this->buildAuthorRows($doi, $summary); + Db::name('production_article_refer_author')->where('p_refer_id', $pReferId)->delete(); + if (empty($rows)) { + return 0; + } + + $now = date('Y-m-d H:i:s'); + $insertRows = []; + foreach ($rows as $row) { + $insertRows[] = [ + 'p_article_id' => $pArticleId, + 'p_refer_id' => $pReferId, + 'author_seq' => intval($row['author_seq']), + 'author_position' => $this->clipField((string)$row['author_position'], 16), + 'is_first_author' => intval($row['is_first_author']), + 'family' => $this->clipField((string)$row['family'], 128), + 'given' => $this->clipField((string)$row['given'], 128), + 'display_name' => $this->clipField((string)$row['display_name'], 256), + 'citation_name' => $this->clipField((string)$row['citation_name'], 128), + 'orcid' => $this->clipField((string)$row['orcid'], 32), + 'openalex_id' => $this->clipField((string)$row['openalex_id'], 32), + 'identity_source' => $this->clipField((string)$row['identity_source'], 32), + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + Db::name('production_article_refer_author')->insertAll($insertRows); + return count($insertRows); + } + + /** + * 从 refer 表已有 author 字段解析并入库(Crossref/OpenAlex 均不可用时的兜底) + * + * @return int + */ + public function syncFromReferAuthorField($pReferId, $pArticleId, $authorString) + { + $pReferId = intval($pReferId); + $pArticleId = intval($pArticleId); + if ($pReferId <= 0) { + return 0; + } + + $rows = $this->parseReferAuthorString($authorString); + Db::name('production_article_refer_author')->where('p_refer_id', $pReferId)->delete(); + if (empty($rows)) { + return 0; + } + + $now = date('Y-m-d H:i:s'); + $insertRows = []; + foreach ($rows as $row) { + $insertRows[] = [ + 'p_article_id' => $pArticleId, + 'p_refer_id' => $pReferId, + 'author_seq' => intval($row['author_seq']), + 'author_position' => $this->clipField((string)$row['author_position'], 16), + 'is_first_author' => intval($row['is_first_author']), + 'family' => '', + 'given' => '', + 'display_name' => $this->clipField((string)$row['display_name'], 256), + 'citation_name' => $this->clipField((string)$row['citation_name'], 128), + 'orcid' => '', + 'openalex_id' => '', + 'identity_source' => 'refer_author', + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + Db::name('production_article_refer_author')->insertAll($insertRows); + return count($insertRows); + } + + /** + * 单条参考文献同步作者明细:有 DOI 走 Crossref+OpenAlex,否则解析 refer.author + * + * @param int $pReferId + * @param int $pArticleId + * @param array $refer 可传 production_article_refer 行;为空则从库读取 + * @return int 写入作者条数 + */ + public function syncOneRefer($pReferId, $pArticleId, array $refer = []) + { + $pReferId = intval($pReferId); + $pArticleId = intval($pArticleId); + if ($pReferId <= 0 || $pArticleId <= 0) { + return 0; + } + + if (empty($refer)) { + $refer = Db::name('production_article_refer') + ->where('p_refer_id', $pReferId) + ->where('p_article_id', $pArticleId) + ->where('state', 0) + ->find(); + if (empty($refer)) { + return 0; + } + } + + $refUtil = new ReferenceCheckService(); + $doi = $refUtil->extractDoiFromRefer($refer); + $count = 0; + + if ($doi !== '') { + $summary = $this->crossref->fetchWorkSummary($doi); + if ($summary === null || empty($summary['doi'])) { + $summary = ['doi' => $doi, 'raw' => []]; + } + $count = $this->syncFromWorkSummary($pReferId, $pArticleId, $doi, $summary); + } + + if ($count <= 0) { + $count = $this->syncFromReferAuthorField( + $pReferId, + $pArticleId, + (string)($refer['author'] ?? '') + ); + } + + return $count; + } + + /** + * 按篇批量同步参考文献作者明细(有 DOI 则 Crossref + OpenAlex) + * + * @return array{total:int,synced:int,authors:int,skipped_no_doi:int,failed:int,errors:array} + */ + public function syncByPArticleId($pArticleId, array $options = []) + { + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + throw new \InvalidArgumentException('p_article_id is required'); + } + + $sleepMs = max(0, intval($options['sleep_ms'] ?? 100)); + $refUtil = new ReferenceCheckService(); + + $refers = Db::name('production_article_refer') + ->field('p_refer_id,p_article_id,index,refer_doi,doilink,refer_content,refer_frag,author') + ->where('p_article_id', $pArticleId) + ->where('state', 0) + ->order('index asc') + ->select(); + + $result = [ + 'p_article_id' => $pArticleId, + 'total' => count($refers), + 'synced' => 0, + 'authors' => 0, + 'skipped_no_doi' => 0, + 'failed' => 0, + 'errors' => [], + ]; + + foreach ($refers as $refer) { + $pReferId = intval($refer['p_refer_id']); + $doi = $refUtil->extractDoiFromRefer($refer); + if ($doi === '') { + $result['skipped_no_doi']++; + continue; + } + + try { + $summary = $this->crossref->fetchWorkSummary($doi); + if ($summary === null || empty($summary['doi'])) { + $summary = ['doi' => $doi, 'raw' => []]; + } + + $count = $this->syncFromWorkSummary($pReferId, $pArticleId, $doi, $summary); + if ($count <= 0) { + $count = $this->syncFromReferAuthorField( + $pReferId, + $pArticleId, + (string)($refer['author'] ?? '') + ); + } + + if ($count > 0) { + $result['synced']++; + $result['authors'] += $count; + } else { + $result['failed']++; + $result['errors'][] = [ + 'p_refer_id' => $pReferId, + 'reference_no' => intval($refer['index']) + 1, + 'doi' => $doi, + 'msg' => 'no author rows from Crossref/OpenAlex/refer.author', + ]; + } + } catch (\Throwable $e) { + $result['failed']++; + $result['errors'][] = [ + 'p_refer_id' => $pReferId, + 'reference_no' => intval($refer['index']) + 1, + 'doi' => $doi, + 'msg' => $e->getMessage(), + ]; + } + + if ($sleepMs > 0) { + usleep($sleepMs * 1000); + } + } + + return $result; + } + + /** + * 读取已入库的作者身份(与 ReferenceAuthorIdentityService 结构兼容) + * + * @return array + */ + public function loadAuthorshipsByPReferId($pReferId) + { + $pReferId = intval($pReferId); + if ($pReferId <= 0) { + return []; + } + + $rows = Db::name('production_article_refer_author') + ->where('p_refer_id', $pReferId) + ->order('author_seq asc, id asc') + ->select(); + + $list = []; + foreach ($rows as $row) { + $openalexId = trim((string)($row['openalex_id'] ?? '')); + $orcid = trim((string)($row['orcid'] ?? '')); + $identityKeys = []; + if ($openalexId !== '') { + $identityKeys[] = 'openalex:' . $openalexId; + } + if ($orcid !== '') { + $identityKeys[] = 'orcid:' . $orcid; + } + if (empty($identityKeys)) { + continue; + } + + $position = trim((string)($row['author_position'] ?? '')); + if ($position === '' && intval($row['is_first_author'] ?? 0) === 1) { + $position = 'first'; + } + + $list[] = [ + 'openalex_id' => $openalexId, + 'orcid' => $orcid, + 'display_name' => trim((string)($row['display_name'] ?? '')), + 'author_position' => $position, + 'is_corresponding' => false, + 'identity_keys' => $identityKeys, + ]; + } + + return $list; + } + + /** + * @return array + */ + private function buildAuthorRows($doi, array $summary) + { + $doi = trim((string)$doi); + $raw = is_array($summary['raw'] ?? null) ? $summary['raw'] : []; + $crossrefAuthors = $this->parseCrossrefAuthorList($raw['author'] ?? []); + $openalexList = $doi !== '' ? $this->fetchOpenAlexAuthorships($doi) : []; + + $max = max(count($crossrefAuthors), count($openalexList)); + if ($max <= 0) { + return []; + } + + $rows = []; + for ($i = 0; $i < $max; $i++) { + $cr = $crossrefAuthors[$i] ?? null; + $oa = $openalexList[$i] ?? null; + + if (is_array($oa) && is_array($cr) && trim((string)($cr['orcid'] ?? '')) !== '' && trim((string)($oa['orcid'] ?? '')) === '') { + $oa['orcid'] = trim((string)$cr['orcid']); + } + if (is_array($oa) && is_array($cr) && trim((string)($oa['display_name'] ?? '')) === '' && trim((string)($cr['display_name'] ?? '')) !== '') { + $oa['display_name'] = trim((string)$cr['display_name']); + } + + $row = $this->mergeOneAuthorRow($i, $cr, $oa); + if ($row !== null) { + $rows[] = $row; + } + } + + return $rows; + } + + private function mergeOneAuthorRow($seq, $crossrefAuthor, $openalexAuthor) + { + $family = ''; + $given = ''; + $displayName = ''; + $citationName = ''; + $orcid = ''; + $openalexId = ''; + $position = ''; + $sources = []; + + if (is_array($crossrefAuthor)) { + $family = trim((string)($crossrefAuthor['family'] ?? '')); + $given = trim((string)($crossrefAuthor['given'] ?? '')); + $displayName = trim((string)($crossrefAuthor['display_name'] ?? '')); + $citationName = trim((string)($crossrefAuthor['citation_name'] ?? '')); + $orcid = trim((string)($crossrefAuthor['orcid'] ?? '')); + $position = trim((string)($crossrefAuthor['author_position'] ?? '')); + $sources[] = 'crossref'; + } + + if (is_array($openalexAuthor)) { + $openalexId = trim((string)($openalexAuthor['openalex_id'] ?? '')); + if (trim((string)($openalexAuthor['orcid'] ?? '')) !== '') { + $orcid = trim((string)$openalexAuthor['orcid']); + } + if (trim((string)($openalexAuthor['display_name'] ?? '')) !== '') { + $displayName = trim((string)$openalexAuthor['display_name']); + } + if (trim((string)($openalexAuthor['author_position'] ?? '')) !== '') { + $position = trim((string)$openalexAuthor['author_position']); + } + $sources[] = 'openalex'; + } + + if ($displayName === '' && ($family !== '' || $given !== '')) { + $displayName = trim($given . ' ' . $family); + } + if ($citationName === '' && is_array($crossrefAuthor) && !empty($crossrefAuthor['raw_author'])) { + $citationName = $this->crossref->getAuthorsCitation(['author' => [$crossrefAuthor['raw_author']]], 1); + } + if ($citationName === '' && $displayName !== '') { + $citationName = $displayName; + } + + if ($displayName === '' && $citationName === '' && $orcid === '' && $openalexId === '') { + return null; + } + + $sources = array_values(array_unique($sources)); + $isFirst = ($position === 'first') || ($seq === 0 && $position === ''); + + return [ + 'author_seq' => intval($seq), + 'author_position' => $position, + 'is_first_author' => $isFirst ? 1 : 0, + 'family' => $family, + 'given' => $given, + 'display_name' => $displayName, + 'citation_name' => $citationName, + 'orcid' => $orcid, + 'openalex_id' => $openalexId, + 'identity_source' => implode('+', $sources), + ]; + } + + /** + * @return array + */ + private function parseCrossrefAuthorList($authorList) + { + if (empty($authorList) || !is_array($authorList)) { + return []; + } + + $parsed = $this->bgCheck->parseCrossRefAuthors($authorList); + $rows = []; + foreach ($parsed as $i => $item) { + $rawAuthor = $authorList[$i] ?? []; + if (!is_array($rawAuthor)) { + $rawAuthor = []; + } + $family = trim((string)($item['family'] ?? '')); + $given = trim((string)($item['given'] ?? '')); + $displayName = trim((string)($item['name'] ?? '')); + $position = trim((string)($rawAuthor['sequence'] ?? '')); + if ($position === '' && $i === 0) { + $position = 'first'; + } + + $rows[] = [ + 'family' => $family, + 'given' => $given, + 'display_name' => $displayName, + 'orcid' => trim((string)($item['orcid'] ?? '')), + 'author_position' => $position, + 'citation_name' => $this->crossref->getAuthorsCitation(['author' => [$rawAuthor]], 1), + 'raw_author' => $rawAuthor, + ]; + } + + return $rows; + } + + /** + * @return array + */ + private function fetchOpenAlexAuthorships($doi) + { + $res = $this->bgCheck->fetchOpenAlexWorkByDoi($doi); + if (empty($res['success']) || empty($res['work']) || !is_array($res['work'])) { + return []; + } + + $list = []; + foreach ($res['work']['authorships'] ?? [] as $auth) { + if (!is_array($auth)) { + continue; + } + $author = is_array($auth['author'] ?? null) ? $auth['author'] : []; + $openalexId = $this->bgCheck->extractOpenAlexId($author['id'] ?? ''); + $orcid = $this->bgCheck->cleanOrcid($author['orcid'] ?? ''); + $displayName = trim((string)($author['display_name'] ?? '')); + if ($openalexId === '' && $orcid === '' && $displayName === '') { + continue; + } + + $list[] = [ + 'openalex_id' => $openalexId, + 'orcid' => $orcid, + 'display_name' => $displayName, + 'author_position' => (string)($auth['author_position'] ?? ''), + 'is_corresponding' => !empty($auth['is_corresponding']), + ]; + } + + return $list; + } + + /** + * @return array + */ + private function parseReferAuthorString($authorString) + { + $authorString = trim((string)$authorString); + if ($authorString === '') { + return []; + } + + $authorString = preg_replace('/\s+et\s+al\.?\s*$/iu', '', $authorString); + $parts = preg_split('/\s*,\s*/u', $authorString); + if (!is_array($parts)) { + return []; + } + + $rows = []; + foreach ($parts as $i => $part) { + $name = trim((string)$part); + if ($name === '' || preg_match('/^et\s+al\.?$/iu', $name)) { + continue; + } + $seq = count($rows); + $rows[] = [ + 'author_seq' => $seq, + 'author_position' => $seq === 0 ? 'first' : 'additional', + 'is_first_author' => $seq === 0 ? 1 : 0, + 'display_name' => $name, + 'citation_name' => $name, + ]; + } + + return $rows; + } + + private function clipField($value, $maxLen) + { + $value = trim((string)$value); + if ($value === '' || $maxLen <= 0) { + return $value; + } + if (mb_strlen($value) <= $maxLen) { + return $value; + } + return mb_substr($value, 0, $maxLen); + } +} diff --git a/application/common/ReferenceRelevanceCheckService.php b/application/common/ReferenceRelevanceCheckService.php new file mode 100644 index 00000000..049602b0 --- /dev/null +++ b/application/common/ReferenceRelevanceCheckService.php @@ -0,0 +1,2415 @@ +refUtil = new ReferenceCheckService(); + $this->logFile = ROOT_PATH . 'runtime' . DS . 'reference_relevance_check.log'; + } + + /** + * 整篇入队:扫描正文引用,写入明细并创建 RabbitMQ 文章批次 + */ + public function enqueueByPArticle(array $prod) + { + $pArticleId = intval($prod['p_article_id']); + $articleId = intval($prod['article_id']); + if ($pArticleId <= 0 || $articleId <= 0) { + throw new \InvalidArgumentException('p_article_id and article_id required'); + } + + DbReconnectHelper::ensure(); + $referMap = $this->refUtil->loadReferMapByPArticleId($pArticleId); + $mains = Db::name('article_main') + ->field('am_id,content,article_id,type,amt_id') + ->where('article_id', $articleId) + ->whereIn('state', [0, 2]) + ->order('sort asc') + ->select(); + if (empty($mains)) { + throw new \RuntimeException('article_main is empty'); + } + + $now = date('Y-m-d H:i:s'); + $pendingJobs = []; + $skipped = 0; + foreach ($mains as $main) { + DbReconnectHelper::release(); + $citations = $this->refUtil->extractReferencesForArticleMain($main); + if (empty($citations)) { + continue; + } + foreach ($citations as $cite) { + foreach ($cite['reference_numbers'] as $refNo) { + $refNo = intval($refNo); + $referIndex = $refNo - 1; + if ($referIndex < 0 || !isset($referMap[$referIndex])) { + $skipped++; + continue; + } + DbReconnectHelper::ensure(); + $checkId = $this->insertRow($prod, $main, $cite, $refNo, $referMap[$referIndex], $now); + if ($checkId <= 0) { + $skipped++; + continue; + } + $pendingJobs[] = [ + 'check_id' => $checkId, + 'reference_no' => $refNo, + 'am_id' => intval($main['am_id']), + 'text_start' => intval($cite['text_start']), + ]; + } + } + } + $checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'enqueue'); + + return [ + 'p_article_id' => $pArticleId, + 'article_id' => $articleId, + 'queued' => count($checkIds), + 'skipped' => $skipped, + 'check_ids' => $checkIds, + 'transport' => self::TRANSPORT_RABBITMQ, + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + + public function resetAndRecheckByArticle(array $prod) + { + $pArticleId = intval($prod['p_article_id']); + $this->clearByPArticleId($pArticleId); + $result = $this->enqueueByPArticle($prod); + $result['reset'] = 1; + $result['cleared'] = 1; + return $result; + } + + public function clearByPArticleId($pArticleId) + { + $pArticleId = intval($pArticleId); + Db::name('article_reference_relevance_check_batch') + ->where('p_article_id', $pArticleId) + ->delete(); + return Db::name('article_reference_relevance_check_result') + ->where('p_article_id', $pArticleId) + ->delete(); + } + + /** + * 仅重新校对 status=0 的记录,不清空历史,也不触发摘要抓取与清洗。 + */ + public function recheckPendingOnlyByArticle($pArticleId) + { + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + throw new \InvalidArgumentException('p_article_id is required'); + } + DbReconnectHelper::ensure(); + $rows = Db::name('article_reference_relevance_check_result') + ->where('p_article_id', $pArticleId) + ->where('status', self::RECORD_PENDING) + ->field('id,reference_no,am_id,text_start') + ->order('reference_no asc,am_id asc,text_start asc,id asc') + ->select(); + if (empty($rows)) { + return [ + 'p_article_id' => $pArticleId, + 'queued' => 0, + 'check_ids' => [], + 'transport' => self::TRANSPORT_RABBITMQ, + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + + $pendingJobs = []; + $checkIds = []; + foreach ($rows as $row) { + $checkId = intval($row['id']); + if ($checkId <= 0) { + continue; + } + $checkIds[] = $checkId; + $pendingJobs[] = [ + 'check_id' => $checkId, + 'reference_no' => intval($row['reference_no']), + 'am_id' => intval($row['am_id']), + 'text_start' => intval($row['text_start']), + ]; + } + if (empty($checkIds)) { + return [ + 'p_article_id' => $pArticleId, + 'queued' => 0, + 'check_ids' => [], + 'transport' => self::TRANSPORT_RABBITMQ, + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + + Db::name('article_reference_relevance_check_result') + ->whereIn('id', $checkIds) + ->update([ + 'queue_status' => self::QUEUE_PENDING, + 'retry_count' => 0, + 'error_msg' => '', + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + $queuedIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'recheck_pending_only'); + return [ + 'p_article_id' => $pArticleId, + 'queued' => count($queuedIds), + 'check_ids' => $queuedIds, + 'transport' => self::TRANSPORT_RABBITMQ, + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + + /** + * 某条参考文献下「校对失败」的明细重新校对(异步) + * + * 不刷新 refer_text / origin_text,只重置结果字段后入 RabbitMQ 批次队列。 + * + * @param int $pReferId + * @param int $pArticleId + * @return array{p_refer_id:int,p_article_id:int,reset:int,queued:int,check_ids:int[],queue:string} + */ + public function enqueueRecheckFailedByPReferId($pReferId, $pArticleId = 0) + { + $pReferId = intval($pReferId); + if ($pReferId <= 0) { + throw new \InvalidArgumentException('p_refer_id is required'); + } + + DbReconnectHelper::ensure(); + $q = Db::name('article_reference_relevance_check_result') + ->where('p_refer_id', $pReferId) + ->where('status', self::RECORD_FAILED); + $pArticleId = intval($pArticleId); + if ($pArticleId > 0) { + $q->where('p_article_id', $pArticleId); + } + + $rows = $q->select(); + if (empty($rows)) { + return [ + 'p_refer_id' => $pReferId, + 'p_article_id' => $pArticleId, + 'reset' => 0, + 'queued' => 0, + 'check_ids' => [], + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + + if ($pArticleId <= 0) { + $pArticleId = intval($rows[0]['p_article_id']); + } + + $now = date('Y-m-d H:i:s'); + $resetFields = $this->relevanceCheckResultResetFields([ + 'updated_at' => $now, + ]); + + $pendingJobs = []; + foreach ($rows as $row) { + $checkId = $this->resolveCheckRowId($row); + if ($checkId <= 0) { + continue; + } + Db::name('article_reference_relevance_check_result')->where('id', $checkId)->update($resetFields); + $pendingJobs[] = [ + 'check_id' => $checkId, + 'reference_no' => intval($row['reference_no']), + 'am_id' => intval($row['am_id']), + 'text_start' => intval($row['text_start']), + ]; + } + + $checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'recheck_failed'); + + return [ + 'p_refer_id' => $pReferId, + 'p_article_id' => $pArticleId, + 'reset' => count($rows), + 'queued' => count($checkIds), + 'check_ids' => $checkIds, + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + + /** + * 失败重跑:扩展到同一引用标签分组(如 [1,2])全部重跑。 + */ + public function enqueueRecheckFailedByPReferIdWithGroup($pReferId, $pArticleId = 0) + { + $pReferId = intval($pReferId); + if ($pReferId <= 0) { + throw new \InvalidArgumentException('p_refer_id is required'); + } + + DbReconnectHelper::ensure(); + $q = Db::name('article_reference_relevance_check_result') + ->where('p_refer_id', $pReferId) + ->where('status', self::RECORD_FAILED); + $pArticleId = intval($pArticleId); + if ($pArticleId > 0) { + $q->where('p_article_id', $pArticleId); + } + + $rows = $q->select(); + if (empty($rows)) { + return [ + 'p_refer_id' => $pReferId, + 'p_article_id' => $pArticleId, + 'reset' => 0, + 'queued' => 0, + 'check_ids' => [], + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + + if ($pArticleId <= 0) { + $pArticleId = intval($rows[0]['p_article_id']); + } + + $now = date('Y-m-d H:i:s'); + $resetFields = $this->relevanceCheckResultResetFields([ + 'updated_at' => $now, + ]); + + $targetRows = []; + foreach ($rows as $row) { + $groupRows = $this->findCitationGroupRows($row); + foreach ($groupRows as $gr) { + $checkId = $this->resolveCheckRowId($gr); + if ($checkId > 0) { + $targetRows[$checkId] = $gr; + } + } + } + + $pendingJobs = []; + foreach ($targetRows as $row) { + $checkId = $this->resolveCheckRowId($row); + Db::name('article_reference_relevance_check_result')->where('id', $checkId)->update($resetFields); + $pendingJobs[] = [ + 'check_id' => $checkId, + 'reference_no' => intval($row['reference_no']), + 'am_id' => intval($row['am_id']), + 'text_start' => intval($row['text_start']), + ]; + } + + $checkIds = $this->enqueueChecksSortedByReferenceNo($pendingJobs, $pArticleId, 'recheck_failed'); + + return [ + 'p_refer_id' => $pReferId, + 'p_article_id' => $pArticleId, + 'reset' => count($targetRows), + 'queued' => count($checkIds), + 'check_ids' => $checkIds, + 'queue' => self::TRANSPORT_RABBITMQ, + ]; + } + + /** + * 执行单条相关性校对 + */ + public function runCheckOnce($checkId, $skipLiteratureFetch = false) + { + DbReconnectHelper::ensure(); + $checkId = intval($checkId); + $row = Db::name('article_reference_relevance_check_result')->where('id', $checkId)->find(); + if (empty($row)) { + throw new \RuntimeException('relevance check row not found, id=' . $checkId); + } + + if (intval($row['status']) === self::RECORD_COMPLETED) { + return $this->formatReturnFromRow($row); + } + + $groupRows = $this->findCitationGroupRows($row); + if ($this->isCitationGroupCheck($groupRows)) { + $leaderRefNo = $this->resolveGroupLeaderRefNo($groupRows); + $currentRefNo = intval($row['reference_no']); + if ($currentRefNo !== $leaderRefNo) { + DbReconnectHelper::ensure(); + $fresh = Db::name('article_reference_relevance_check_result')->where('id', $checkId)->find(); + if (!empty($fresh) && intval($fresh['status']) === self::RECORD_COMPLETED) { + return $this->formatReturnFromRow($fresh); + } + throw new \RuntimeException('Citation group leader not finished, reference_no=' . $leaderRefNo); + } + } + + DbReconnectHelper::release(); + DbReconnectHelper::ensure(); + $sectionText = $this->refUtil->resolveMainContentForJob($row); + $localContext = $this->resolveLocalContextForJob($row); + $citeGroupRefs = $this->resolveCiteGroupRefs($row, $groupRows); + $referText = $this->buildCombinedReferText($groupRows); + $referTypeMap = $this->resolveReferTypeMap($groupRows); + if ($skipLiteratureFetch) { + $literatureBundle = $this->resolveGroupLiteratureBundle($groupRows, $referTypeMap, false); + $abstractText = $literatureBundle['combined_text']; + } else { + // 优先读 t_production_article_refer;摘要与清洗内容都为空时再抓取并回写 refer 表 + DbReconnectHelper::release(); + $literatureBundle = $this->resolveGroupLiteratureBundle($groupRows, $referTypeMap, true); + $abstractText = $literatureBundle['combined_text']; + DbReconnectHelper::ensure(); + } + + $noLiteratureEvidence = !$literatureBundle['has_verification_evidence']; + if ($noLiteratureEvidence && trim((string)$abstractText) === '') { + // 无摘要/全文时,退化为仅基于参考文献书目信息校对,并打标识供前端区分 + $abstractText = "【文献书目信息(无摘要/全文)】\n" . $referText; + } + + if ($sectionText === '' || $referText === '') { + $msg = 'Missing section content or refer_text'; + $this->failGroupWithQueue($groupRows, $msg); + throw new \RuntimeException($msg); + } + + DbReconnectHelper::release(); + $llm = (new ReferenceRelevanceLlmService())->checkRelevance( + $sectionText, + $localContext, + $referText, + $abstractText, + $citeGroupRefs, + $referTypeMap + ); + DbReconnectHelper::ensure(); + + if (!empty($llm['request_failed']) || !$this->applyGroupResults($groupRows, $llm)) { + $msg = isset($llm['reason']) ? (string)$llm['reason'] : 'LLM failed or empty results'; + $this->failGroupWithQueue($groupRows, $msg); + throw new \RuntimeException($msg); + } + if ($noLiteratureEvidence) { + $this->markGroupNoLiteratureEvidence($groupRows); + } + + $this->markGroupQueueRuntime($groupRows, self::QUEUE_COMPLETED); + + $fresh = Db::name('article_reference_relevance_check_result')->where('id', $checkId)->find(); + return $this->formatReturnFromRow(!empty($fresh) ? $fresh : $row); + } + + public function getProgressByPArticleId($pArticleId) + { + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + throw new \InvalidArgumentException('p_article_id is required'); + } + + $rows = Db::name('article_reference_relevance_check_result') + ->where('p_article_id', $pArticleId) + ->order('reference_no asc, id asc') + ->select(); + + $summary = ['pending' => 0, 'checking' => 0, 'completed' => 0, 'failed' => 0]; + if (empty($rows)) { + return [ + 'p_article_id' => $pArticleId, + 'total_groups' => 0, + 'summary' => $summary, + 'list' => [], + ]; + } + + $groups = []; + + foreach ($rows as $row) { + $refNo = intval($row['reference_no']); + $pReferId = intval($row['p_refer_id']); + if (!isset($groups[$refNo])) { + $groups[$refNo] = [ + 'reference_no' => $refNo, + 'p_refer_id' => $pReferId, + 'total' => 0, + 'pending' => 0, + 'done' => 0, + 'failed' => 0, + 'pass' => 0, + 'last_updated_at' => '', + 'records' => [], + ]; + } + if ($groups[$refNo]['p_refer_id'] <= 0 && $pReferId > 0) { + $groups[$refNo]['p_refer_id'] = $pReferId; + } + + $g = &$groups[$refNo]; + $g['total']++; + $st = intval($row['status']); + if ($st === self::RECORD_PENDING) { + $g['pending']++; + } elseif ($st === self::RECORD_COMPLETED) { + $g['done']++; + } elseif ($st === self::RECORD_FAILED) { + $g['failed']++; + } + + $upd = (string)(isset($row['updated_at']) ? $row['updated_at'] : ''); + if ($upd > $g['last_updated_at']) { + $g['last_updated_at'] = $upd; + } + + $score = floatval($row['relevance_score']); + $isPass = $score >= self::PASS_SCORE_THRESHOLD; + if ($isPass) { + $g['pass']++; + } + + $claims = $this->decodeClaimsJson(isset($row['claims_json']) ? $row['claims_json'] : ''); + $g['records'][] = [ + 'check_id' => intval($row['id']), + 'am_id' => intval($row['am_id']), + 'status' => $st, + 'is_relevant' => intval($row['is_relevant']), + 'relevance_score' => $score, + 'is_pass' => $isPass, + 'reason' => (string)$row['reason'], + 'author_comment' => $this->resolveAuthorCommentFromRow($row, $claims), + 'combined_relevance_score' => floatval($row['combined_relevance_score']), + 'combined_reason' => (string)$row['combined_reason'], + 'cite_group_refs' => (string)$row['cite_group_refs'], + 'claims' => $claims, + 'evidence_mode' => ((string)($row['score_ceiling_trigger'] ?? '') === 'no_literature_evidence') + ? 'bibliographic_only' + : 'literature_evidence', + 'has_literature_evidence' => ((string)($row['score_ceiling_trigger'] ?? '') !== 'no_literature_evidence'), + 'cite_check_mode' => strpos((string)$row['cite_group_refs'], ',') !== false ? 'joint' : 'single', + 'origin_text' => (string)$row['origin_text'], + 'last_updated_at' => $upd, + ]; + unset($g); + } + + $list = []; + foreach ($groups as $g) { + $total = $g['total']; + $pending = $g['pending']; + $failed = $g['failed']; + $pass = $g['pass']; + if ($pending === $total) { + $ps = 0; + } elseif ($pending === 0) { + $ps = $failed > 0 ? 3 : 2; + } else { + $ps = 1; + } + $g['progress_status'] = $ps; + $g['is_pass'] = ($ps === 2 && $pass === $total && $total > 0); + switch ($ps) { + case 0: $summary['pending']++; break; + case 1: $summary['checking']++; break; + case 2: $summary['completed']++; break; + case 3: $summary['failed']++; break; + } + $list[] = $g; + } + + usort($list, function ($a, $b) { + return $a['reference_no'] - $b['reference_no']; + }); + + return [ + 'p_article_id' => $pArticleId, + 'total_groups' => count($list), + 'summary' => $summary, + 'list' => $list, + ]; + } + + /** + * 按 p_article_id 查整篇文章的相关性校对总状态(按 reference_no 分组统计) + * + * @return array{p_article_id:int, status:int, total:int, pending:int, done:int, failed:int, progress_percent:float} + */ + public function getArticleProgressStatusByPArticleId($pArticleId) + { + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + throw new \InvalidArgumentException('p_article_id is required'); + } + + $rows = Db::name('article_reference_relevance_check_result') + ->field('reference_no' + . ', SUM(CASE WHEN status = ' . self::RECORD_PENDING . ' THEN 1 ELSE 0 END) AS pending_cnt' + . ', SUM(CASE WHEN status = ' . self::RECORD_FAILED . ' THEN 1 ELSE 0 END) AS failed_cnt') + ->where('p_article_id', $pArticleId) + ->group('reference_no') + ->select(); + + if (empty($rows)) { + return [ + 'p_article_id' => $pArticleId, + 'status' => self::ARTICLE_PROGRESS_NONE, + 'total' => 0, + 'pending' => 0, + 'done' => 0, + 'failed' => 0, + 'progress_percent' => 0, + ]; + } + + $pending = 0; + $done = 0; + $failed = 0; + foreach ($rows as $row) { + $pendingCnt = intval(isset($row['pending_cnt']) ? $row['pending_cnt'] : 0); + $failedCnt = intval(isset($row['failed_cnt']) ? $row['failed_cnt'] : 0); + if ($pendingCnt > 0) { + $pending++; + } elseif ($failedCnt > 0) { + $failed++; + } else { + $done++; + } + } + + $total = count($rows); + $articleStatus = $pending > 0 + ? self::ARTICLE_PROGRESS_RUNNING + : self::ARTICLE_PROGRESS_COMPLETED; + $finished = $done + $failed; + $progressPercent = round($finished / $total * 100, 1); + + return [ + 'p_article_id' => $pArticleId, + 'status' => $articleStatus, + 'total' => $total, + 'pending' => $pending, + 'done' => $done, + 'failed' => $failed, + 'progress_percent' => $progressPercent, + ]; + } + + /** + * 按 p_refer_id 查单条参考文献的相关性校对明细与分组进度 + * + * @return array{p_refer_id:int,p_article_id:int,reference_no:int,total:int,pending:int,done:int,failed:int,pass:int,progress_status:int,progress_percent:float,is_pass:bool,last_updated_at:string,list:array} + */ + public function getDetailsByPReferId($pReferId) + { + $pReferId = intval($pReferId); + if ($pReferId <= 0) { + throw new \InvalidArgumentException('p_refer_id is required'); + } + + $rows = Db::name('article_reference_relevance_check_result') + ->where('p_refer_id', $pReferId) + ->order('id asc') + ->select(); + + $list = []; + $pArticleId = 0; + $referenceNo = 0; + $pending = 0; + $done = 0; + $failed = 0; + $pass = 0; + $lastUpdatedAt = ''; + + foreach ($rows as $row) { + if ($pArticleId <= 0) { + $pArticleId = intval($row['p_article_id']); + } + if ($referenceNo <= 0) { + $referenceNo = intval($row['reference_no']); + } + + $st = intval($row['status']); + if ($st === self::RECORD_PENDING) { + $pending++; + } elseif ($st === self::RECORD_COMPLETED) { + $done++; + } elseif ($st === self::RECORD_FAILED) { + $failed++; + } + + $upd = (string)(isset($row['updated_at']) ? $row['updated_at'] : ''); + if ($upd > $lastUpdatedAt) { + $lastUpdatedAt = $upd; + } + + $score = floatval($row['relevance_score']); + $isPass = $score >= self::PASS_SCORE_THRESHOLD; + if ($isPass) { + $pass++; + } + + $item = $this->formatReturnFromRow($row); + $item['cite_check_mode'] = strpos((string)$row['cite_group_refs'], ',') !== false ? 'joint' : 'single'; + $item['is_pass'] = $isPass; + $list[] = $item; + } + + if ($referenceNo <= 0) { + $refer = Db::name('production_article_refer') + ->where('p_refer_id', $pReferId) + ->where('state', 0) + ->find(); + if (!empty($refer)) { + if ($pArticleId <= 0) { + $pArticleId = intval($refer['p_article_id']); + } + $referenceNo = intval($refer['index']) + 1; + } + } + + $total = count($list); + if ($total === 0) { + $progressStatus = 0; + $progressPercent = 0; + $isPassGroup = false; + } elseif ($pending === $total) { + $progressStatus = 0; + $progressPercent = 0; + $isPassGroup = false; + } elseif ($pending === 0) { + $progressStatus = $failed > 0 ? 3 : 2; + $progressPercent = 100; + $isPassGroup = ($progressStatus === 2 && $pass === $total); + } else { + $progressStatus = 1; + $finished = $done + $failed; + $progressPercent = round($finished / $total * 100, 1); + $isPassGroup = false; + } + + return [ + 'p_refer_id' => $pReferId, + 'p_article_id' => $pArticleId, + 'reference_no' => $referenceNo, + 'total' => $total, + 'pending' => $pending, + 'done' => $done, + 'failed' => $failed, + 'pass' => $pass, + 'progress_status' => $progressStatus, + 'progress_percent' => $progressPercent, + 'is_pass' => $isPassGroup, + 'last_updated_at' => $lastUpdatedAt, + 'list' => $list, + ]; + } + + public function markQueueRuntime($checkId, $queueStatus, $retryCount = null) + { + DbReconnectHelper::ensure(); + $fields = [ + 'queue_status' => intval($queueStatus), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + if ($retryCount !== null) { + $fields['retry_count'] = max(0, intval($retryCount)); + } + return Db::name('article_reference_relevance_check_result') + ->where('id', intval($checkId)) + ->update($fields); + } + + /** + * 修复卡住队列:已完成但 queue 未同步;长时间 RUNNING 回退为待执行 + */ + public function recoverQueueRowsForArticle($pArticleId) + { + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + return; + } + DbReconnectHelper::ensure(); + $now = date('Y-m-d H:i:s'); + Db::name('article_reference_relevance_check_result') + ->where('p_article_id', $pArticleId) + ->where('status', self::RECORD_COMPLETED) + ->where('queue_status', '<>', self::QUEUE_COMPLETED) + ->update([ + 'queue_status' => self::QUEUE_COMPLETED, + 'updated_at' => $now, + ]); + // 仅回收“长时间未更新”的 RUNNING,避免多消费者并发时把正在执行的任务误回退成 PENDING + $runningStaleBefore = date('Y-m-d H:i:s', time() - 600); + Db::name('article_reference_relevance_check_result') + ->where('p_article_id', $pArticleId) + ->where('queue_status', self::QUEUE_RUNNING) + ->where('status', self::RECORD_PENDING) + ->where('updated_at', '<', $runningStaleBefore) + ->update([ + 'queue_status' => self::QUEUE_PENDING, + 'updated_at' => $now, + ]); + $staleBefore = date('Y-m-d H:i:s', time() - 600); + Db::name('article_reference_relevance_check_result') + ->where('p_article_id', $pArticleId) + ->where('queue_status', self::QUEUE_RUNNING) + ->where('status', self::RECORD_FAILED) + ->where('updated_at', '<', $staleBefore) + ->update([ + 'queue_status' => self::QUEUE_FAILED, + 'updated_at' => $now, + ]); + } + + /** + * 校对前预处理:按 DOI 多源抓取(Europe PMC → PubMed → PMC → Unpaywall PDF → Crossref; + * cma.j.cn 走 OpenAlex,暂不调用 Yiigle 机构 API),清洗后写入 t_production_article_refer_literature。 + * + * @return array{ + * p_article_id:int, + * total:int, + * cached:int, + * fetched:int, + * fallback:int, + * empty:int, + * items:array + * } + */ + public function prepareLiteratureContentByArticle($pArticleId) + { + $pArticleId = intval($pArticleId); + $summary = [ + 'p_article_id' => $pArticleId, + 'total' => 0, + 'cached' => 0, + 'fetched' => 0, + 'fallback' => 0, + 'empty' => 0, + 'items' => [], + ]; + if ($pArticleId <= 0) { + return $summary; + } + + DbReconnectHelper::ensure(); + $rows = Db::name('article_reference_relevance_check_result') + ->where('p_article_id', $pArticleId) + ->field('p_refer_id,reference_no') + ->select(); + if (empty($rows)) { + return $summary; + } + + $needReferIds = []; + foreach ($rows as $row) { + $pReferId = intval($row['p_refer_id'] ?? 0); + if ($pReferId <= 0) { + continue; + } + if (!isset($needReferIds[$pReferId])) { + $needReferIds[$pReferId] = intval($row['reference_no'] ?? 0); + } + } + if (empty($needReferIds)) { + return $summary; + } + + $this->log('prepare literature start p_article_id=' . $pArticleId . ' refer_count=' . count($needReferIds)); + + foreach ($needReferIds as $pReferId => $referenceNo) { + $summary['total']++; + $item = $this->prepareLiteratureContentForRefer($pArticleId, intval($pReferId), intval($referenceNo)); + $summary['items'][] = $item; + $status = (string)($item['status'] ?? 'empty'); + if (isset($summary[$status])) { + $summary[$status]++; + } else { + $summary['empty']++; + } + } + + $this->log( + 'prepare literature done p_article_id=' . $pArticleId + . ' total=' . $summary['total'] + . ' fetched=' . $summary['fetched'] + . ' cached=' . $summary['cached'] + . ' fallback=' . $summary['fallback'] + . ' empty=' . $summary['empty'] + ); + + return $summary; + } + + /** + * 从相关性校对明细按 p_refer_id 去重,抓取 API 原文写入 t_production_article_refer_literature(不 LLM 清洗)。 + * + * @return array{p_article_id:int,total:int,cached:int,fetched:int,empty:int,items:array} + */ + public function fetchReferLiteratureByRelevanceArticle($pArticleId, $forceRefetch = false) + { + $pArticleId = intval($pArticleId); + $summary = [ + 'p_article_id' => $pArticleId, + 'total' => 0, + 'cached' => 0, + 'fetched' => 0, + 'empty' => 0, + 'items' => [], + ]; + if ($pArticleId <= 0) { + return $summary; + } + + DbReconnectHelper::ensure(); + $rows = Db::name('article_reference_relevance_check_result') + ->where('p_article_id', $pArticleId) + ->field('p_refer_id,reference_no') + ->select(); + if (empty($rows)) { + return $summary; + } + + $needReferIds = []; + foreach ($rows as $row) { + $pReferId = intval($row['p_refer_id'] ?? 0); + if ($pReferId <= 0) { + continue; + } + if (!isset($needReferIds[$pReferId])) { + $needReferIds[$pReferId] = intval($row['reference_no'] ?? 0); + } + } + if (empty($needReferIds)) { + return $summary; + } + + $this->log('fetch refer literature(raw) start p_article_id=' . $pArticleId . ' refer_count=' . count($needReferIds)); + + foreach ($needReferIds as $pReferId => $referenceNo) { + $summary['total']++; + $item = $this->fetchReferLiteratureRawForRefer( + $pArticleId, + intval($pReferId), + intval($referenceNo), + (bool)$forceRefetch + ); + $summary['items'][] = $item; + $status = (string)($item['status'] ?? 'empty'); + if (isset($summary[$status])) { + $summary[$status]++; + } else { + $summary['empty']++; + } + } + + $this->log( + 'fetch refer literature(raw) done p_article_id=' . $pArticleId + . ' total=' . $summary['total'] + . ' fetched=' . $summary['fetched'] + . ' cached=' . $summary['cached'] + . ' empty=' . $summary['empty'] + ); + + return $summary; + } + + /** + * 单条参考文献:仅 API 抓取,写入 t_production_article_refer_literature(不清洗、不改主表)。 + */ + public function fetchReferLiteratureRawForRefer($pArticleId, $pReferId, $referenceNo = 0, $forceRefetch = false) + { + $pArticleId = intval($pArticleId); + $pReferId = intval($pReferId); + $referenceNo = intval($referenceNo); + $item = [ + 'p_refer_id' => $pReferId, + 'reference_no' => $referenceNo, + 'status' => 'empty', + 'sources' => [], + 'fetch_log' => '', + 'abstract_len' => 0, + 'content_len' => 0, + 'mesh_len' => 0, + 'literature_pdf_url' => '', + ]; + if ($pReferId <= 0) { + return $item; + } + + $litSvc = new ProductionArticleReferLiteratureService(); + if (!$forceRefetch) { + $stored = $litSvc->getByPReferId($pReferId, $pArticleId); + if (!empty($stored) && $this->storedLiteratureRowHasFetchedContent($stored)) { + $item['status'] = 'cached'; + $item['abstract_len'] = mb_strlen((string)($stored['abstract_text'] ?? '')); + $item['content_len'] = mb_strlen((string)($stored['content_text'] ?? '')); + $item['mesh_len'] = mb_strlen((string)($stored['mesh_terms'] ?? '')); + $item['literature_pdf_url'] = trim((string)($stored['literature_pdf_url'] ?? '')); + $item['fetch_log'] = 'literature_table_cache'; + $item['sources'] = array_values(array_filter(explode(',', (string)($stored['fetch_sources'] ?? '')))); + + return $item; + } + } + + DbReconnectHelper::ensure(); + $q = Db::name('production_article_refer')->where('p_refer_id', $pReferId); + if ($pArticleId > 0) { + $q->where('p_article_id', $pArticleId); + } + $refer = $q->find(); + if (empty($refer)) { + $item['fetch_log'] = 'refer_not_found'; + return $item; + } + + DbReconnectHelper::release(); + $bundle = (new ReferenceLiteratureFetchService())->setSkipYiigle(true)->fetchForRefer($refer); + DbReconnectHelper::ensure(); + + $abstract = trim((string)($bundle['abstract'] ?? '')); + $content = trim((string)($bundle['raw_content'] ?? '')); + if ($content === '' && $abstract !== '') { + $content = $abstract; + } + $pdfUrl = trim((string)($bundle['pdf_url'] ?? '')); + $mesh = $litSvc->formatMeshTerms($bundle['mesh_terms'] ?? []); + + $item['sources'] = array_values((array)($bundle['sources'] ?? [])); + $item['fetch_log'] = trim((string)($bundle['fetch_log'] ?? '')); + $item['literature_pdf_url'] = $pdfUrl; + + if ($abstract === '' && $content === '' && $mesh === '' && $pdfUrl === '') { + return $item; + } + + $referDoi = trim((string)($refer['refer_doi'] ?? '')); + if ($referDoi === '') { + $referDoi = trim((string)($refer['doilink'] ?? '')); + } + if ($referDoi === '') { + $referDoi = trim((string)($bundle['doi'] ?? '')); + } + + $litSvc->upsert($pArticleId, $pReferId, [ + 'refer_doi' => $referDoi, + 'abstract_text' => $abstract, + 'content_text' => $content, + 'mesh_terms' => $mesh, + 'refer_content_cleaned' => '', + 'literature_pdf_url' => $pdfUrl, + 'fetch_sources' => $bundle['sources'] ?? [], + 'fetch_log' => $bundle['fetch_log'] ?? '', + ]); + + $item['status'] = 'fetched'; + $item['abstract_len'] = mb_strlen($abstract); + $item['content_len'] = mb_strlen($content); + $item['mesh_len'] = mb_strlen($mesh); + + return $item; + } + + private function storedLiteratureRowHasFetchedContent(array $stored) + { + if (trim((string)($stored['content_text'] ?? '')) !== '') { + return true; + } + if (trim((string)($stored['abstract_text'] ?? '')) !== '') { + return true; + } + if (trim((string)($stored['mesh_terms'] ?? '')) !== '') { + return true; + } + + return trim((string)($stored['literature_pdf_url'] ?? '')) !== ''; + } + + /** + * 单条参考文献:DOI 多源抓取 + LLM 清洗,写入 t_production_article_refer_literature。 + * + * @return array{ + * p_refer_id:int, + * reference_no:int, + * status:string, + * sources:array, + * fetch_log:string, + * abstract_len:int, + * cleaned_len:int, + * literature_pdf_url:string, + * has_verifiable_fetch:bool + * } + */ + public function prepareLiteratureContentForRefer($pArticleId, $pReferId, $referenceNo = 0) + { + $pArticleId = intval($pArticleId); + $pReferId = intval($pReferId); + $referenceNo = intval($referenceNo); + $item = [ + 'p_refer_id' => $pReferId, + 'reference_no' => $referenceNo, + 'status' => 'empty', + 'sources' => [], + 'fetch_log' => '', + 'abstract_len' => 0, + 'cleaned_len' => 0, + 'literature_pdf_url' => '', + 'has_verifiable_fetch' => false, + ]; + if ($pReferId <= 0) { + return $item; + } + + DbReconnectHelper::ensure(); + $q = Db::name('production_article_refer')->where('p_refer_id', $pReferId); + if ($pArticleId > 0) { + $q->where('p_article_id', $pArticleId); + } + $refer = $q->find(); + if (empty($refer)) { + $item['fetch_log'] = 'refer_not_found'; + return $item; + } + + $litSvc = new ProductionArticleReferLiteratureService(); + $lit = $litSvc->loadForCheck($pReferId, $pArticleId); + $abstract = trim((string)($lit['abstract_text'] ?? '')); + $cleaned = trim((string)($lit['refer_content_cleaned'] ?? '')); + $contentText = trim((string)($lit['content_text'] ?? '')); + $fetchSvc = new ReferenceLiteratureFetchService(); + if ($abstract !== '' || $cleaned !== '' || $contentText !== '') { + // 规则统一:同一 p_refer_id 只要子表已有内容即视为缓存命中,不再触发外部抓取 + $substantive = $this->isSubstantiveLiteratureContent($abstract) + || $this->isSubstantiveLiteratureContent($cleaned) + || ($cleaned === '' && $this->isSubstantiveLiteratureContent($contentText)); + $item['status'] = 'cached'; + $item['abstract_len'] = mb_strlen($abstract); + $item['cleaned_len'] = mb_strlen($cleaned !== '' ? $cleaned : $contentText); + $item['literature_pdf_url'] = trim((string)($lit['literature_pdf_url'] ?? '')); + $item['has_verifiable_fetch'] = $substantive; + $item['fetch_log'] = 'literature_table_cache'; + return $item; + } + + DbReconnectHelper::release(); + $bundle = $fetchSvc->setSkipYiigle(true)->fetchAndCleanForRefer($refer); + DbReconnectHelper::ensure(); + + $abstract = trim((string)($bundle['abstract_final'] ?? '')); + $raw = trim((string)($bundle['raw_content'] ?? '')); + $cleaned = trim((string)($bundle['content_cleaned'] ?? '')); + if ($cleaned === '' && $raw !== '') { + $cleaned = mb_substr($raw, 0, 6000); + } + + $item['sources'] = array_values((array)($bundle['sources'] ?? [])); + $item['fetch_log'] = trim((string)($bundle['fetch_log'] ?? '')); + $pdfUrl = trim((string)($bundle['pdf_url'] ?? '')); + $hasVerifiableFetch = $this->hasVerifiableFetchFromBundle($bundle); + + if ($abstract === '' && $cleaned === '') { + $fallback = $this->buildReferBibliographicFallback($refer, $this->refUtil->formatReferForLlm($refer)); + if ($fallback !== '') { + $cleaned = $fallback; + $hasVerifiableFetch = false; + $this->persistProductionReferLiterature($pArticleId, $pReferId, '', $cleaned, $pdfUrl, $bundle); + $item['status'] = 'fallback'; + $item['cleaned_len'] = mb_strlen($cleaned); + $item['literature_pdf_url'] = $pdfUrl; + $item['has_verifiable_fetch'] = false; + return $item; + } + $item['status'] = 'empty'; + $item['literature_pdf_url'] = $pdfUrl; + return $item; + } + + $this->persistProductionReferLiterature($pArticleId, $pReferId, $abstract, $cleaned, $pdfUrl, $bundle); + $item['status'] = 'fetched'; + $item['abstract_len'] = mb_strlen($abstract); + $item['cleaned_len'] = mb_strlen($cleaned); + $item['literature_pdf_url'] = $pdfUrl; + $item['has_verifiable_fetch'] = $hasVerifiableFetch; + + return $item; + } + + public function markGroupQueueRuntime(array $groupRows, $queueStatus, $retryCount = null) + { + foreach ($groupRows as $gr) { + $checkId = intval(isset($gr['id']) ? $gr['id'] : 0); + if ($checkId > 0) { + $this->markQueueRuntime($checkId, $queueStatus, $retryCount); + } + } + } + + public function failGroupWithQueue(array $groupRows, $msg, $retryCount = null) + { + $this->failGroup($groupRows, $msg); + $this->markGroupQueueRuntime($groupRows, self::QUEUE_FAILED, $retryCount); + } + + public function resolveCheckRowId($row) + { + if (!is_array($row)) { + return 0; + } + return intval(isset($row['id']) ? $row['id'] : 0); + } + + public function updateCheckResult($checkId, array $fields) + { + return $this->updateRow(intval($checkId), $fields); + } + + /** + * @param array $rows 元素含 check_id + * @param int $pArticleId + * @param string $trigger + * @return int[] + */ + public function enqueueChecksSortedByReferenceNo(array $rows, $pArticleId = 0, $trigger = 'enqueue') + { + usort($rows, function ($a, $b) { + if ($a['reference_no'] !== $b['reference_no']) { + return $a['reference_no'] - $b['reference_no']; + } + if ($a['am_id'] !== $b['am_id']) { + return $a['am_id'] - $b['am_id']; + } + return $a['text_start'] - $b['text_start']; + }); + + $checkIds = []; + foreach ($rows as $row) { + $checkId = intval($row['check_id']); + if ($checkId > 0) { + $checkIds[] = $checkId; + } + } + if (!empty($checkIds)) { + $this->startArticleRelevanceQueue($checkIds, intval($pArticleId), $trigger); + } + return $checkIds; + } + + /** + * 创建文章批次;队首批次立即发 MQ,其余批次链式等待前序完成 + * + * @param int[] $checkIds + * @param int $pArticleId + * @param string $trigger + * @return int[] + */ + public function startArticleRelevanceQueue(array $checkIds, $pArticleId = 0, $trigger = 'enqueue') + { + $checkIds = array_values(array_filter(array_map('intval', $checkIds))); + if (empty($checkIds)) { + return []; + } + + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + $firstRow = Db::name('article_reference_relevance_check_result')->where('id', $checkIds[0])->find(); + $pArticleId = empty($firstRow) ? 0 : intval($firstRow['p_article_id']); + } + if ($pArticleId <= 0) { + throw new \RuntimeException('p_article_id is required for relevance check queue'); + } + + $now = date('Y-m-d H:i:s'); + DbReconnectHelper::ensure(); + $batchId = Db::name('article_reference_relevance_check_batch')->insertGetId([ + 'p_article_id' => $pArticleId, + 'batch_status' => 0, + 'total_count' => count($checkIds), + 'done_count' => 0, + 'failed_count' => 0, + 'trigger' => (string)$trigger, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $shouldPublish = !$this->hasEarlierWaitingBatch($batchId) && !$this->hasRunningRelevanceBatch(); + if ($shouldPublish) { + DbReconnectHelper::release(); + (new ReferenceCheckMqPublisher())->publishArticleStart($pArticleId, intval($batchId), $trigger); + DbReconnectHelper::ensure(); + $this->log('startArticleRelevanceQueue publish p_article_id=' . $pArticleId . ' batch_id=' . $batchId); + } else { + $this->log('startArticleRelevanceQueue queued batch_id=' . $batchId . ' p_article_id=' . $pArticleId); + } + + return $checkIds; + } + + private function hasRunningRelevanceBatch() + { + return Db::name('article_reference_relevance_check_batch') + ->where('batch_status', 1) + ->count() > 0; + } + + private function hasEarlierWaitingBatch($batchId) + { + return Db::name('article_reference_relevance_check_batch') + ->where('batch_status', 0) + ->where('id', '<', intval($batchId)) + ->count() > 0; + } + + /** + * 多篇文章并行校对时,查询指定文章前面还有几篇在排队。 + */ + public function getArticleCheckQueuePositionByPArticleId($pArticleId) + { + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + throw new \InvalidArgumentException('p_article_id is required'); + } + + $rows = Db::name('article_reference_relevance_check_result') + ->field('p_article_id, MIN(id) AS queue_anchor') + ->where('status', self::RECORD_PENDING) + ->group('p_article_id') + ->order('queue_anchor', 'asc') + ->select(); + + $runningIds = []; + foreach ($rows as $row) { + $aid = intval(isset($row['p_article_id']) ? $row['p_article_id'] : 0); + if ($aid > 0) { + $runningIds[] = $aid; + } + } + + $runningTotal = count($runningIds); + $ahead = 0; + $position = 0; + $inQueue = false; + foreach ($runningIds as $idx => $aid) { + if ($aid === $pArticleId) { + $ahead = $idx; + $position = $idx + 1; + $inQueue = true; + break; + } + } + + $articleStatus = $this->getArticleProgressStatusByPArticleId($pArticleId); + + return [ + 'p_article_id' => $pArticleId, + 'running_total' => $runningTotal, + 'ahead' => $inQueue ? $ahead : 0, + 'position' => $inQueue ? $position : 0, + 'in_queue' => $inQueue, + 'status' => intval(isset($articleStatus['status']) ? $articleStatus['status'] : self::ARTICLE_PROGRESS_NONE), + ]; + } + + public function log($msg) + { + $line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL; + @file_put_contents($this->logFile, $line, FILE_APPEND); + } + + private function insertRow(array $prod, array $main, array $cite, $refNo, array $refer, $now) + { + $meta = $this->citationMeta($cite); + $originText = trim((string)$meta['origin_text']); + + return intval(Db::name('article_reference_relevance_check_result')->insertGetId([ + 'article_id' => intval($prod['article_id']), + 'p_article_id' => intval($prod['p_article_id']), + 'am_id' => intval($main['am_id']), + 'p_refer_id' => intval($refer['p_refer_id']), + 'reference_no' => intval($refNo), + 'cite_group_refs' => $meta['cite_group_refs'], + 'cite_tag_start' => $meta['cite_tag_start'], + 'cite_tag_end' => $meta['cite_tag_end'], + 'text_start' => $meta['text_start'], + 'text_end' => $meta['text_end'], + 'origin_text' => $originText, + 'refer_text' => $this->refUtil->formatReferForLlm($refer), + 'abstract_text' => '', + 'refer_content_cleaned' => '', + 'author_comment' => '', + 'status' => self::RECORD_PENDING, + 'queue_status' => self::QUEUE_PENDING, + 'retry_count' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ])); + } + + private function citationMeta(array $cite) + { + $nums = []; + foreach ((array)$cite['reference_numbers'] as $n) { + $n = intval($n); + if ($n > 0) { + $nums[$n] = $n; + } + } + $list = array_values($nums); + sort($list, SORT_NUMERIC); + + return [ + 'cite_group_refs' => implode(',', $list), + 'cite_tag_start' => intval($cite['reference_start']), + 'cite_tag_end' => intval($cite['reference_end']), + 'origin_text' => (string)$cite['original_text'], + 'text_start' => intval($cite['text_start']), + 'text_end' => intval($cite['text_end']), + ]; + } + + public function findCitationGroupRowsForWorker(array $row) + { + return $this->findCitationGroupRows($row); + } + + private function findCitationGroupRows(array $row) + { + $amId = intval($row['am_id']); + if ($amId <= 0) { + return [$row]; + } + $q = Db::name('article_reference_relevance_check_result')->where('am_id', $amId); + $citeTagStart = intval($row['cite_tag_start']); + $citeTagEnd = intval($row['cite_tag_end']); + if ($citeTagStart > 0 && $citeTagEnd > $citeTagStart) { + $q->where('cite_tag_start', $citeTagStart)->where('cite_tag_end', $citeTagEnd); + } else { + $q->where('text_start', intval($row['text_start'])) + ->where('text_end', intval($row['text_end'])) + ->where('cite_group_refs', (string)$row['cite_group_refs']); + } + $rows = $q->order('reference_no asc')->select(); + return empty($rows) ? [$row] : $rows; + } + + private function isCitationGroupCheck(array $groupRows) + { + return count($groupRows) > 1; + } + + private function resolveGroupLeaderRefNo(array $groupRows) + { + $leader = PHP_INT_MAX; + foreach ($groupRows as $gr) { + $refNo = intval($gr['reference_no']); + if ($refNo > 0 && $refNo < $leader) { + $leader = $refNo; + } + } + return $leader === PHP_INT_MAX ? 0 : $leader; + } + + private function resolveCiteGroupRefs(array $row, array $groupRows) + { + $refs = trim((string)$row['cite_group_refs']); + if ($refs !== '') { + return $refs; + } + $nums = []; + foreach ($groupRows as $gr) { + $n = intval($gr['reference_no']); + if ($n > 0) { + $nums[$n] = $n; + } + } + $list = array_values($nums); + sort($list, SORT_NUMERIC); + return implode(',', $list); + } + + private function buildCombinedLiteratureText(array $groupRows) + { + return $this->resolveGroupLiteratureBundle($groupRows, [], true)['combined_text']; + } + + /** + * 抓取/读取组内各文献内容,并判断是否存在可校对的外部证据(摘要/全文)。 + * + * @return array{combined_text:string,has_verification_evidence:bool} + */ + private function resolveGroupLiteratureBundle(array $groupRows, array $referTypeMap = [], $fetchIfMissing = true) + { + $blocks = []; + $hasEvidence = false; + foreach ($groupRows as $gr) { + $refNo = intval($gr['reference_no']); + if ($refNo <= 0) { + continue; + } + $referType = 'journal'; + if (!empty($referTypeMap[$refNo]['type'])) { + $referType = (string)$referTypeMap[$refNo]['type']; + } + $lit = $this->resolveLiteratureForGroupRow($gr, $fetchIfMissing); + if ($this->literatureHasVerificationEvidence($lit, $referType)) { + $hasEvidence = true; + } + $text = $this->formatLiteratureBlockForLlm($lit['abstract'], $lit['cleaned']); + if ($text !== '') { + $blocks[] = '【参考文献 ' . $refNo . "】\n" . $text; + } + } + + return [ + 'combined_text' => implode("\n\n", $blocks), + 'has_verification_evidence' => $hasEvidence, + ]; + } + + /** + * @return array{abstract:string,cleaned:string,from_bibliographic_fallback:bool,has_verifiable_fetch:bool} + */ + private function resolveLiteratureForGroupRow(array $gr, $fetchIfMissing = true) + { + $pArticleId = intval($gr['p_article_id'] ?? 0); + $pReferId = intval($gr['p_refer_id'] ?? 0); + if ($pReferId <= 0) { + return [ + 'abstract' => '', + 'cleaned' => '', + 'from_bibliographic_fallback' => false, + 'has_verifiable_fetch' => false, + ]; + } + + $lit = $this->ensureProductionReferLiterature( + $pArticleId, + $pReferId, + $fetchIfMissing, + trim((string)($gr['refer_text'] ?? '')) + ); + + $checkId = intval($gr['id'] ?? 0); + if ($checkId > 0 && ($lit['abstract'] !== '' || $lit['cleaned'] !== '')) { + $this->updateRow($checkId, [ + 'abstract_text' => $lit['abstract'], + 'refer_content_cleaned' => $lit['cleaned'], + ]); + } + + return $lit; + } + + private function literatureHasVerificationEvidence(array $lit, $referType = 'journal') + { + $abstract = trim((string)($lit['abstract'] ?? '')); + $cleaned = trim((string)($lit['cleaned'] ?? '')); + if ($abstract !== '' && $this->isSubstantiveLiteratureContent($abstract)) { + return true; + } + if ($referType === 'book') { + return $cleaned !== ''; + } + if (!empty($lit['has_verifiable_fetch'])) { + return true; + } + if (!empty($lit['from_bibliographic_fallback'])) { + return false; + } + + return $cleaned !== '' && $this->isSubstantiveLiteratureContent($cleaned); + } + + /** + * 无摘要/无外部文献内容时跳过 LLM 校对,标记为已完成并写明原因。 + */ + private function skipGroupNoLiterature(array $groupRows) + { + $reason = '未获取文献摘要或全文,已跳过自动相关性校对,请人工核对或补充文献内容后重跑。'; + $now = date('Y-m-d H:i:s'); + foreach ($groupRows as $gr) { + $checkId = intval($gr['id'] ?? 0); + if ($checkId <= 0) { + continue; + } + $this->updateRow($checkId, [ + 'is_relevant' => 0, + 'relevance_score' => 0, + 'reason' => $reason, + 'author_comment' => '', + 'combined_relevance_score' => 0, + 'combined_reason' => $reason, + 'claims_json' => '', + 'status' => self::RECORD_COMPLETED, + 'queue_status' => self::QUEUE_COMPLETED, + 'error_msg' => '', + 'updated_at' => $now, + ]); + } + $this->markGroupQueueRuntime($groupRows, self::QUEUE_COMPLETED); + $this->log('skip relevance check: no literature evidence, group_size=' . count($groupRows)); + } + + private function markGroupNoLiteratureEvidence(array $groupRows) + { + foreach ($groupRows as $gr) { + $checkId = intval($gr['id'] ?? 0); + if ($checkId <= 0) { + continue; + } + $this->updateRow($checkId, [ + 'score_ceiling_trigger'=> 'no_literature_evidence', + ]); + } + } + + /** + * 仅使用 t_production_article_refer_literature 已入库摘要/清洗内容,不触发抓取。 + */ + private function buildCombinedStoredLiteratureText(array $groupRows) + { + $blocks = []; + foreach ($groupRows as $gr) { + $refNo = intval($gr['reference_no']); + $text = $this->resolveLiteratureContentForGroupRow($gr, false); + if ($refNo > 0 && $text !== '') { + $blocks[] = '【参考文献 ' . $refNo . "】\n" . $text; + } + } + return implode("\n\n", $blocks); + } + + /** + * 按 p_article_id + p_refer_id 从 t_production_article_refer_literature 取摘要/清洗内容; + * 二者都为空且允许抓取时再外部获取并回写文献表。 + */ + private function resolveLiteratureContentForGroupRow(array $gr, $fetchIfMissing = true) + { + $pArticleId = intval($gr['p_article_id'] ?? 0); + $pReferId = intval($gr['p_refer_id'] ?? 0); + if ($pReferId <= 0) { + return ''; + } + + $lit = $this->ensureProductionReferLiterature( + $pArticleId, + $pReferId, + $fetchIfMissing, + trim((string)($gr['refer_text'] ?? '')) + ); + + $checkId = intval($gr['id'] ?? 0); + if ($checkId > 0 && ($lit['abstract'] !== '' || $lit['cleaned'] !== '')) { + $this->updateRow($checkId, [ + 'abstract_text' => $lit['abstract'], + 'refer_content_cleaned' => $lit['cleaned'], + ]); + } + + if ($lit['abstract'] === '' && $lit['cleaned'] === '') { + return ''; + } + + return $this->formatLiteratureBlockForLlm($lit['abstract'], $lit['cleaned']); + } + + /** + * @return array{ + * abstract:string, + * cleaned:string, + * from_bibliographic_fallback:bool, + * has_verifiable_fetch:bool + * } + */ + private function ensureProductionReferLiterature($pArticleId, $pReferId, $fetchIfMissing = true, $referTextFallback = '') + { + $empty = [ + 'abstract' => '', + 'cleaned' => '', + 'from_bibliographic_fallback' => false, + 'has_verifiable_fetch' => false, + ]; + $pArticleId = intval($pArticleId); + $pReferId = intval($pReferId); + if ($pReferId <= 0) { + return $empty; + } + + DbReconnectHelper::ensure(); + $q = Db::name('production_article_refer')->where('p_refer_id', $pReferId); + if ($pArticleId > 0) { + $q->where('p_article_id', $pArticleId); + } + $refer = $q->find(); + if (empty($refer)) { + return $empty; + } + + $litSvc = new ProductionArticleReferLiteratureService(); + $storedLitRow = $litSvc->getByPReferId($pReferId, $pArticleId); + $lit = $litSvc->loadForCheck($pReferId, $pArticleId); + $abstract = trim((string)($lit['abstract_text'] ?? '')); + $cleaned = trim((string)($lit['refer_content_cleaned'] ?? '')); + $contentText = trim((string)($lit['content_text'] ?? '')); + if ($abstract !== '' || $cleaned !== '' || $contentText !== '') { + // 规则:同一 p_refer_id 只要已有缓存内容,直接复用,不再触发 refetch + $substantive = $this->isSubstantiveLiteratureContent($abstract) + || $this->isSubstantiveLiteratureContent($cleaned) + || ($cleaned === '' && $this->isSubstantiveLiteratureContent($contentText)); + $useCleaned = $cleaned !== '' ? $cleaned : $contentText; + + return [ + 'abstract' => $abstract, + 'cleaned' => $useCleaned, + 'from_bibliographic_fallback' => ($abstract === '' && $useCleaned !== '' && !$substantive), + 'has_verifiable_fetch' => $substantive, + ]; + } + if ($fetchIfMissing && !empty($storedLitRow)) { + $prevFetchLog = trim((string)($lit['fetch_log'] ?? '')); + $prevUpdatedAt = trim((string)($storedLitRow['updated_at'] ?? '')); + $prevTs = $prevUpdatedAt !== '' ? strtotime($prevUpdatedAt) : false; + $isRecent = ($prevTs !== false) && ((time() - $prevTs) < 259200); // 72 小时内空抓取不重复打外部源 + $wasEmptyFetch = (strpos($prevFetchLog, 'fetch_empty') !== false) + || (strpos($prevFetchLog, 'no_doi_and_bibliographic_search_failed') !== false) + || (strpos($prevFetchLog, 'refer_not_found') !== false); + if ($wasEmptyFetch && $isRecent) { + $this->log('literature fetch cooldown hit p_refer_id=' . $pReferId . ' log=' . $prevFetchLog); + return $empty; + } + } + + if (!$fetchIfMissing) { + return $empty; + } + + DbReconnectHelper::release(); + $bundle = (new ReferenceLiteratureFetchService())->fetchAndCleanForRefer($refer); + DbReconnectHelper::ensure(); + + $abstract = trim((string)($bundle['abstract_final'] ?? '')); + $raw = trim((string)($bundle['raw_content'] ?? '')); + $cleaned = trim((string)($bundle['content_cleaned'] ?? '')); + if ($cleaned === '' && $raw !== '') { + $cleaned = mb_substr($raw, 0, 6000); + } + $hasVerifiableFetch = $this->hasVerifiableFetchFromBundle($bundle); + $fromBibliographicFallback = false; + $pdfUrl = trim((string)($bundle['pdf_url'] ?? '')); + + if ($abstract === '' && $cleaned === '') { + $fallback = $this->buildReferBibliographicFallback($refer, $referTextFallback); + if ($fallback !== '') { + $fetchLog = trim((string)($bundle['fetch_log'] ?? 'fetch_empty')); + $this->log('literature bibliographic fallback p_refer_id=' . $pReferId . ' log=' . $fetchLog); + $cleaned = $fallback; + $fromBibliographicFallback = true; + $this->persistProductionReferLiterature($pArticleId, $pReferId, '', $cleaned, $pdfUrl, $bundle); + return [ + 'abstract' => '', + 'cleaned' => $cleaned, + 'from_bibliographic_fallback' => true, + 'has_verifiable_fetch' => false, + ]; + } + $bundle['fetch_log'] = trim((string)($bundle['fetch_log'] ?? 'fetch_empty')); + $this->persistProductionReferLiterature($pArticleId, $pReferId, '', '', $pdfUrl, $bundle); + $this->log('literature fetch empty p_refer_id=' . $pReferId . ' log=' . $bundle['fetch_log']); + return $empty; + } + + $this->persistProductionReferLiterature($pArticleId, $pReferId, $abstract, $cleaned, $pdfUrl, $bundle); + return [ + 'abstract' => $abstract, + 'cleaned' => $cleaned, + 'from_bibliographic_fallback' => $fromBibliographicFallback, + 'has_verifiable_fetch' => $hasVerifiableFetch, + ]; + } + + private function hasVerifiableFetchFromBundle(array $bundle) + { + $sources = (array)($bundle['sources'] ?? []); + $strongSources = ['cma_openalex', 'cma_yiigle', 'pmc_fulltext', 'unpaywall_pdf']; + if (!empty(array_intersect($sources, $strongSources))) { + return true; + } + $raw = (string)($bundle['raw_content'] ?? ''); + if (in_array('europe_pmc', $sources, true) && $this->isSubstantiveLiteratureContent($raw)) { + return true; + } + if (in_array('crossref', $sources, true) && preg_match('/Abstract:\s*(.{40,})/s', $raw)) { + return true; + } + $abstract = trim((string)($bundle['abstract_final'] ?? $bundle['abstract'] ?? '')); + + return $abstract !== '' && $this->isSubstantiveLiteratureContent($abstract); + } + + private function storedCleanedLooksFetched($cleaned) + { + return $this->isSubstantiveLiteratureContent($cleaned); + } + + /** + * 可校对证据:摘要/全文;排除 PubMed 仅 MeSH/书目元数据。 + */ + private function isSubstantiveLiteratureContent($text) + { + $text = trim((string)$text); + if ($text === '') { + return false; + } + if (preg_match('/(?:^|\n)Abstract:\s*(.+)/us', $text, $m)) { + return mb_strlen(trim($m[1])) >= 40; + } + if (preg_match('/【摘要】\s*(.+)/us', $text, $m)) { + return mb_strlen(trim($m[1])) >= 40; + } + if (preg_match('/===\s*(PMC Full Text|OA PDF|中华医学期刊全文)/u', $text)) { + $body = preg_replace('/^===[^=]+===\s*\n?/us', '', $text); + + return mb_strlen(trim($body)) >= 200; + } + if (preg_match('/=== Europe PMC ===\s*\n(.+)/s', $text, $m)) { + return mb_strlen(trim($m[1])) >= 200; + } + if (preg_match('/=== PubMed/u', $text)) { + return false; + } + if (preg_match('/^(?:MeSH:|Publication Types:)/um', $text) && mb_strlen($text) < 500) { + return false; + } + if (mb_strlen($text) >= 400 && !preg_match('/^MeSH:/um', $text)) { + return true; + } + + return false; + } + + /** + * 外部源(PMC/PubMed/Crossref/PDF)无摘要时,用参考书目信息兜底供 LLM 判断。 + */ + private function buildReferBibliographicFallback(array $refer, $referTextFallback = '') + { + $isBook = strtolower(trim((string)($refer['refer_type'] ?? ''))) === 'book' + || trim((string)($refer['isbn'] ?? '')) !== ''; + + $blocks = []; + if ($isBook) { + foreach (['title', 'author', 'joura', 'dateno', 'isbn'] as $field) { + $val = trim((string)($refer[$field] ?? '')); + if ($val !== '') { + $blocks[] = ucfirst($field) . ': ' . $val; + } + } + } else { + $formatted = $this->refUtil->formatReferForLlm($refer); + if ($formatted !== '') { + $blocks[] = $formatted; + } + foreach (['refer_frag', 'refer_content'] as $field) { + $text = trim((string)($refer[$field] ?? '')); + if ($text === '') { + continue; + } + if ($field === 'refer_content' && $this->referSnippetLooksMismatched($refer, $text)) { + continue; + } + if ($formatted !== '' && strpos($formatted, $text) !== false) { + continue; + } + $blocks[] = $text; + } + } + + $referTextFallback = trim((string)$referTextFallback); + if ($referTextFallback !== '' + && !in_array($referTextFallback, $blocks, true) + && !$this->referSnippetLooksMismatched($refer, $referTextFallback)) { + $blocks[] = $referTextFallback; + } + + $blocks = array_values(array_unique(array_filter($blocks))); + if (empty($blocks)) { + return ''; + } + + $sourceText = implode("\n\n", $blocks); + $bookMeta = $this->formatBookBibliographicMeta($sourceText, $refer); + if ($bookMeta !== '') { + array_unshift($blocks, $bookMeta); + } + + return mb_substr(implode("\n\n", $blocks), 0, 6000); + } + + /** + * refer_content 等原始片段是否与 refer 行 title/author 明显不是同一文献。 + */ + private function referSnippetLooksMismatched(array $refer, $snippet) + { + $snippet = trim((string)$snippet); + if ($snippet === '') { + return false; + } + $fetchSvc = new ReferenceLiteratureFetchService(); + + return !$fetchSvc->storedContentMatchesRefer($refer, '', $snippet); + } + + /** + * 识别无 DOI 的图书/教材参考文献,生成结构化书目块供 LLM 判断。 + */ + private function formatBookBibliographicMeta($sourceText, array $refer = []) + { + $sourceText = trim((string)$sourceText); + if ($sourceText === '') { + return ''; + } + + $hasIsbn = preg_match('/\bISBN[:\s]*([\d\-Xx]{10,17})/i', $sourceText, $isbnMatch); + $hasEdition = preg_match('/\b\d+(?:st|nd|rd|th)\s+ed\.?/i', $sourceText); + $hasPublisherYear = preg_match('/;\s*(19|20)\d{2}\s*\.?/i', $sourceText); + $hasDoi = trim((string)($refer['refer_doi'] ?? '')) !== '' + || trim((string)($refer['doilink'] ?? '')) !== '' + || preg_match('/\b10\.\d{4,9}\//i', $sourceText); + + if (!$hasIsbn && !($hasEdition && $hasPublisherYear) && stripos($sourceText, 'ISBN') === false) { + return ''; + } + if ($hasDoi && !$hasIsbn) { + return ''; + } + + $lines = [ + '【文献类型】图书/教材(无 DOI、无外部摘要;请据书名、副标题、作者、出版社、版本、ISBN 判断类型与主题,不得仅因缺少摘要就给 0.25)', + ]; + + $title = trim((string)($refer['title'] ?? '')); + if ($title === '' && preg_match('/\.\s*([^.]+(?:Models|Theories|Knowledge|Nursing)[^.]*)\s*\./i', $sourceText, $m)) { + $title = trim($m[1]); + } + if ($title !== '') { + $lines[] = '【书名】' . $title; + } + + $author = trim((string)($refer['author'] ?? '')); + if ($author === '' && preg_match('/^([A-Z][A-Za-z\-]+(?:\s+[A-Z][A-Za-z\-]+)*(?:\s*,\s*[A-Z][A-Za-z\-]+(?:\s+[A-Z][A-Za-z\-]+)*)*)\./m', $sourceText, $m)) { + $author = trim($m[1]); + } + if ($author !== '') { + $lines[] = '【作者】' . $author; + } + + $year = ''; + if (preg_match('/;\s*((19|20)\d{2})/', $sourceText, $m)) { + $year = $m[1]; + } elseif (trim((string)($refer['dateno'] ?? '')) !== '') { + $year = trim((string)$refer['dateno']); + } + $publisher = trim((string)($refer['joura'] ?? '')); + if ($publisher !== '' || $year !== '') { + $lines[] = '【出版信息】' . trim($publisher . ($publisher !== '' && $year !== '' ? '; ' : '') . $year); + } + + if ($hasIsbn && !empty($isbnMatch[1])) { + $lines[] = '【ISBN】' . trim($isbnMatch[1]); + } + + return implode("\n", $lines); + } + + private function persistProductionReferLiterature($pArticleId, $pReferId, $abstract, $cleaned, $pdfUrl = '', array $bundle = []) + { + $pArticleId = intval($pArticleId); + $pReferId = intval($pReferId); + if ($pReferId <= 0) { + return; + } + + $abstract = trim((string)$abstract); + $cleaned = trim((string)$cleaned); + $pdfUrl = trim((string)$pdfUrl); + $contentText = trim((string)($bundle['raw_content'] ?? '')); + if ($pdfUrl === '') { + $pdfUrl = trim((string)($bundle['pdf_url'] ?? '')); + } + + DbReconnectHelper::ensure(); + $q = Db::name('production_article_refer')->where('p_refer_id', $pReferId); + if ($pArticleId > 0) { + $q->where('p_article_id', $pArticleId); + } + $refer = $q->find(); + $referDoi = ''; + if (!empty($refer)) { + $referDoi = trim((string)($refer['refer_doi'] ?? '')); + if ($referDoi === '') { + $referDoi = trim((string)($refer['doilink'] ?? '')); + } + } + if ($referDoi === '') { + $referDoi = trim((string)($bundle['doi'] ?? '')); + } + + (new ProductionArticleReferLiteratureService())->upsert($pArticleId, $pReferId, [ + 'refer_doi' => $referDoi, + 'abstract_text' => $abstract, + 'content_text' => $contentText, + 'mesh_terms' => $bundle['mesh_terms'] ?? [], + 'refer_content_cleaned' => $cleaned, + 'literature_pdf_url' => $pdfUrl, + 'fetch_sources' => $bundle['sources'] ?? [], + 'fetch_log' => $bundle['fetch_log'] ?? '', + ]); + } + + private function formatLiteratureBlockForLlm($abstract, $cleaned) + { + $parts = []; + if (trim((string)$abstract) !== '') { + $parts[] = "【摘要】\n" . trim((string)$abstract); + } + if (trim((string)$cleaned) !== '') { + $label = trim((string)$abstract) === '' + ? '【文献书目信息(无外部摘要,据参考书目判断)】' + : '【清洗后文献内容】'; + $parts[] = $label . "\n" . trim((string)$cleaned); + } + + return implode("\n\n", $parts); + } + + /** + * @deprecated 使用 buildCombinedLiteratureText + */ + private function buildCombinedAbstractText(array $groupRows) + { + return $this->buildCombinedLiteratureText($groupRows); + } + + /** + * @deprecated 使用 resolveLiteratureContentForGroupRow + */ + private function resolveAbstractTextForGroupRow(array $gr) + { + return $this->resolveLiteratureContentForGroupRow($gr); + } + + private function buildCombinedReferText(array $groupRows) + { + $blocks = []; + foreach ($groupRows as $gr) { + $refNo = intval($gr['reference_no']); + $text = trim((string)$gr['refer_text']); + if ($refNo > 0 && $text !== '') { + $blocks[] = '【参考文献 ' . $refNo . "】\n" . $text; + } + } + return implode("\n\n", $blocks); + } + + /** + * 解析引用组内每条文献的类型(图书/期刊/其他),供 LLM 分轨校对。 + * + * @return array reference_no => 类型信息 + */ + private function resolveReferTypeMap(array $groupRows) + { + $pReferIds = []; + foreach ($groupRows as $gr) { + $pReferId = intval($gr['p_refer_id'] ?? 0); + if ($pReferId > 0) { + $pReferIds[$pReferId] = $pReferId; + } + } + + $referById = []; + if (!empty($pReferIds)) { + DbReconnectHelper::ensure(); + $rows = Db::name('production_article_refer') + ->field('p_refer_id,refer_type,isbn,refer_doi,doilink,refer_content,refer_frag') + ->whereIn('p_refer_id', array_values($pReferIds)) + ->select(); + foreach ($rows as $r) { + $referById[intval($r['p_refer_id'])] = $r; + } + } + + $map = []; + foreach ($groupRows as $gr) { + $refNo = intval($gr['reference_no']); + if ($refNo <= 0) { + continue; + } + $refer = $referById[intval($gr['p_refer_id'] ?? 0)] ?? []; + $type = $this->normalizeReferType($refer, trim((string)($gr['refer_text'] ?? ''))); + $map[$refNo] = [ + 'type' => $type, + 'check_mode' => $type === 'book' ? 'bibliographic_inference' : ($type === 'journal' ? 'abstract_verification' : 'best_effort'), + ]; + } + + return $map; + } + + /** + * 归一化文献类型:优先取 refer_type 字段,其次按 ISBN/DOI 规则兜底。 + */ + private function normalizeReferType(array $refer, $referTextFallback = '') + { + $type = strtolower(trim((string)($refer['refer_type'] ?? ''))); + if ($type === 'book' || $type === 'journal') { + return $type; + } + + $isbn = trim((string)($refer['isbn'] ?? '')); + $hasDoi = trim((string)($refer['refer_doi'] ?? '')) !== '' + || trim((string)($refer['doilink'] ?? '')) !== ''; + + $sourceText = $referTextFallback; + foreach (['refer_content', 'refer_frag'] as $field) { + $sourceText .= ' ' . trim((string)($refer[$field] ?? '')); + } + + if ($isbn !== '' || preg_match('/\bISBN\b/i', $sourceText) + || preg_match('/\b\d+(?:st|nd|rd|th)\s+ed\.?/i', $sourceText)) { + return 'book'; + } + if ($hasDoi || preg_match('/\b10\.\d{4,9}\//', $sourceText)) { + return 'journal'; + } + + return 'other'; + } + + private function applyGroupResults(array $groupRows, array $llmResponse) + { + $results = isset($llmResponse['results']) && is_array($llmResponse['results']) + ? $llmResponse['results'] : []; + if (empty($results)) { + return false; + } + + $combinedScore = floatval(isset($llmResponse['combined_relevance_score']) ? $llmResponse['combined_relevance_score'] : 0); + $combinedReason = trim((string)(isset($llmResponse['combined_reason']) ? $llmResponse['combined_reason'] : '')); + $claimsJson = $this->encodeClaimsJson(isset($llmResponse['claims']) ? $llmResponse['claims'] : []); + + $byRef = []; + foreach ($results as $item) { + if (!is_array($item)) { + continue; + } + $refNo = intval(isset($item['reference_no']) ? $item['reference_no'] : 0); + if ($refNo > 0) { + $byRef[$refNo] = $item; + } + } + + $expected = 0; + $applied = 0; + foreach ($groupRows as $gr) { + $refNo = intval($gr['reference_no']); + if ($refNo <= 0) { + continue; + } + $expected++; + if (!isset($byRef[$refNo])) { + continue; + } + $item = $byRef[$refNo]; + $rowCombinedScore = $combinedScore > 0 + ? $combinedScore + : floatval(isset($item['combined_relevance_score']) ? $item['combined_relevance_score'] : $item['relevance_score']); + $rowCombinedReason = $combinedReason !== '' + ? $combinedReason + : (string)(isset($item['combined_reason']) ? $item['combined_reason'] : $item['reason']); + $this->updateRow(intval($gr['id']), [ + 'is_relevant' => !empty($item['is_relevant']) ? 1 : 0, + 'relevance_score' => floatval($item['relevance_score']), + 'reason' => (string)$item['reason'], + 'author_comment' => (string)($item['author_comment'] ?? ''), + 'combined_relevance_score' => $rowCombinedScore, + 'combined_reason' => $rowCombinedReason, + 'claims_json' => $claimsJson, + 'status' => self::RECORD_COMPLETED, + 'error_msg' => '', + ]); + $applied++; + } + + return $expected > 0 && $applied === $expected; + } + + private function failGroup(array $groupRows, $msg) + { + $msg = mb_substr(trim((string)$msg), 0, 512); + foreach ($groupRows as $gr) { + $this->updateRow(intval($gr['id']), [ + 'status' => self::RECORD_FAILED, + 'error_msg' => $msg, + ]); + } + } + + private function updateRow($checkId, array $fields) + { + DbReconnectHelper::ensure(); + if (isset($fields['reason'])) { + $fields['reason'] = mb_substr(trim((string)$fields['reason']), 0, 2000); + } + if (isset($fields['author_comment'])) { + $fields['author_comment'] = mb_substr(trim((string)$fields['author_comment']), 0, 2000); + } + if (isset($fields['combined_reason'])) { + $fields['combined_reason'] = mb_substr(trim((string)$fields['combined_reason']), 0, 2000); + } + if (isset($fields['claims_json'])) { + $fields['claims_json'] = mb_substr(trim((string)$fields['claims_json']), 0, 4000); + } + $fields['updated_at'] = date('Y-m-d H:i:s'); + return Db::name('article_reference_relevance_check_result') + ->where('id', intval($checkId)) + ->update($fields); + } + + private function relevanceCheckResultResetFields(array $extra = []) + { + return array_merge([ + 'status' => self::RECORD_PENDING, + 'queue_status' => self::QUEUE_PENDING, + 'retry_count' => 0, + 'is_relevant' => 0, + 'relevance_score' => 0, + 'reason' => '', + 'author_comment' => '', + 'combined_relevance_score' => 0, + 'combined_reason' => '', + 'claims_json' => '', + 'score_ceiling_trigger' => '', + 'error_msg' => '', + ], $extra); + } + + /** + * 引用处局部上下文:优先相关性专用方法,线上旧版 ReferenceCheckService 回退到支撑力度同款方法。 + */ + private function resolveLocalContextForJob(array $row) + { + if (method_exists($this->refUtil, 'resolveCitationLocalContextForRelevanceJob')) { + return $this->refUtil->resolveCitationLocalContextForRelevanceJob($row); + } + if (method_exists($this->refUtil, 'resolveCitationLocalContextForJob')) { + return $this->refUtil->resolveCitationLocalContextForJob($row); + } + + return trim((string)(isset($row['origin_text']) ? $row['origin_text'] : '')); + } + + private function formatReturnFromRow(array $row) + { + $claims = $this->decodeClaimsJson(isset($row['claims_json']) ? $row['claims_json'] : ''); + $reason = (string)$row['reason']; + if (!empty($claims)) { + $parts = []; + foreach ($claims as $k => $v) { + $key = trim((string)$k); + if (is_array($v)) { + $val = trim((string)json_encode($v, JSON_UNESCAPED_UNICODE)); + } else { + $val = trim((string)$v); + } + if ($val === '') { + continue; + } + $parts[] = ($key !== '' ? ($key . ':') : '') . $val; + } + if (!empty($parts)) { + $claimsText = mb_substr(implode(';', $parts), 0, 2000); + $reason = $claimsText . "\n" . $reason; + } + } + $author_comment = $this->resolveAuthorCommentFromRow($row, $claims); + if($author_comment){ + $reason = $reason . "\n" . $author_comment; + } + return [ + 'check_id' => intval($row['id']), + 'p_refer_id' => intval($row['p_refer_id']), + 'reference_no' => intval($row['reference_no']), + 'am_id' => intval($row['am_id']), + 'status' => intval($row['status']), + 'is_relevant' => intval($row['is_relevant']), + 'relevance_score' => floatval($row['relevance_score']), + 'reason' => $reason, + 'author_comment' => $author_comment, + 'combined_relevance_score' => floatval($row['combined_relevance_score']), + 'combined_reason' => (string)$row['combined_reason'], + 'cite_group_refs' => (string)$row['cite_group_refs'], + 'claims' => $claims, + 'evidence_mode' => ((string)($row['score_ceiling_trigger'] ?? '') === 'no_literature_evidence') + ? 'bibliographic_only' + : 'literature_evidence', + 'has_literature_evidence' => ((string)($row['score_ceiling_trigger'] ?? '') !== 'no_literature_evidence'), + 'origin_text' => (string)$row['origin_text'], + ]; + } + + /** + * @param array|string $claims + */ + private function encodeClaimsJson($claims) + { + if (is_string($claims)) { + $claims = trim($claims); + if ($claims === '') { + return ''; + } + $decoded = json_decode($claims, true); + if (is_array($decoded) && !empty($decoded)) { + return mb_substr(json_encode($decoded, JSON_UNESCAPED_UNICODE), 0, 4000); + } + return mb_substr($claims, 0, 4000); + } + if (!is_array($claims) || empty($claims)) { + return ''; + } + + return mb_substr(json_encode($claims, JSON_UNESCAPED_UNICODE), 0, 4000); + } + + private function decodeClaimsJson($json) + { + $json = trim((string)$json); + if ($json === '') { + return []; + } + $decoded = json_decode($json, true); + + return is_array($decoded) ? $decoded : []; + } + + private function resolveAuthorCommentFromRow(array $row, array $claims = []) + { + $stored = trim((string)($row['author_comment'] ?? '')); + if ($stored !== '') { + return $stored; + } + + return $this->buildAuthorCommentForDisplay( + floatval($row['relevance_score'] ?? 0), + (string)($row['reason'] ?? ''), + $claims + ); + } + + private function buildAuthorCommentForDisplay($score, $reason, array $claims = []) + { + $score = floatval($score); + if ($score > 0.65 + 0.001) { + return ''; + } + if (!empty($claims)) { + $targets = []; + foreach ($claims as $txt) { + $txt = trim((string)$txt); + if ($txt !== '') { + $targets[] = $txt; + } + if (count($targets) >= 2) { + break; + } + } + if (!empty($targets)) { + return '建议补充可直接支撑“' . implode('”“', $targets) . '”等关键表述的文献,或适当调整正文表述。'; + } + } + $reason = trim((string)$reason); + if ($reason === '') { + return '该条文献对当前表述支撑较弱,建议补充更直接覆盖核心论点的参考文献。'; + } + $reason = preg_replace('/Claim覆盖[::][^。;;\n]*/u', '', $reason); + $reason = preg_replace('/\b[A-E]\s*(?:[✔✘]|部分)\b/u', '', $reason); + $reason = preg_replace('/[✔✘]/u', '', $reason); + $reason = preg_replace('/\b0?\.\d{1,2}\b/u', '', $reason); + $reason = preg_replace('/故\s*(?:联合分?)?\s*[01](?:\.\d+)?[。.]?/u', '', $reason); + $reason = trim((string)$reason); + if ($reason === '') { + return '该条文献对当前表述支撑较弱,建议补充更直接覆盖核心论点的参考文献。'; + } + + return mb_substr('建议:' . $reason, 0, 120); + } + /** + * 按 p_article_id 清空整篇文章的引用校对明细 + 重置节级 ref_check_status。 + * + * 用于新增/删除文献后,旧的 reference_no 全部错位、原校对结果失效的场景: + * 物理删除后,整篇状态查询自然回到 ARTICLE_PROGRESS_NONE(未校对)。 + * + * @return int 被删除的明细条数 + */ + public function clearArticleChecksByPArticleId($pArticleId,$articleId=0) + { + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + return 0; + } + + // 先反查 article_id(用于重置 article_main.ref_check_status 节级状态) + if($articleId==0){ + $articleId = intval(Db::name('production_article') + ->where('p_article_id', $pArticleId) + ->whereIn('state', [0, 2]) + ->value('article_id')); + } + + $deleted = Db::name('article_reference_relevance_check_result') + ->where('p_article_id', $pArticleId) + ->delete(); + + if ($articleId > 0 && $this->hasAmRefCheckStatusColumn()) { + Db::name('article_main') + ->where('article_id', $articleId) + ->whereIn('state', [0, 2]) + ->update(['ref_check_status' => self::AM_STATUS_NONE]); + } + + return intval($deleted); + } +} diff --git a/application/common/ReferenceStackingStatsService.php b/application/common/ReferenceStackingStatsService.php new file mode 100644 index 00000000..635234c2 --- /dev/null +++ b/application/common/ReferenceStackingStatsService.php @@ -0,0 +1,834 @@ +refUtil = new ReferenceCheckService(); + $this->crossref = new CrossrefService([ + 'mailto' => trim((string)\think\Env::get('crossref_mailto', '')), + ]); + $this->identity = new ReferenceAuthorIdentityService(); + } + + /** + * 按阈值规则实时统计:同作者(>15%)、同刊(>20%)、自引(>10%) + * + * @param int $pArticleId + * @return array + */ + public function getThresholdStackingByPArticleId($pArticleId) + { + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + throw new \InvalidArgumentException('p_article_id is required'); + } + + return $this->formatThresholdStackingReport($this->compute($pArticleId)); + } + + /** + * @param array $full compute/analyze 或 getStored 的完整结果 + */ + public function formatThresholdStackingReport(array $full) + { + $total = max(1, intval($full['total_references'] ?? 0)); + $referMap = (array)($full['refer_map'] ?? []); + if (empty($referMap) && intval($full['p_article_id'] ?? 0) > 0) { + $referMap = $this->loadReferMapByPArticleId(intval($full['p_article_id'])); + } + + $authorItems = []; + foreach ((array)($full['author_details'] ?? []) as $item) { + $count = intval($item['ref_count'] ?? 0); + $ratio = $count / $total; + if ($ratio <= self::THRESHOLD_SAME_AUTHOR) { + continue; + } + $pReferIds = array_values((array)($item['p_refer_ids'] ?? [])); + $authorItems[] = [ + 'author_name' => (string)($item['group_name'] ?? ''), + 'orcid' => (string)($item['orcid'] ?? ''), + 'cite_count' => $count, + 'cite_ratio' => round($ratio, 4), + 'threshold' => self::THRESHOLD_SAME_AUTHOR, + 'exceeded' => true, + 'p_refer_ids' => $pReferIds, + 'reference_nos' => array_values((array)($item['reference_nos'] ?? [])), + 'references' => $this->buildReferBriefs($pReferIds, $referMap), + ]; + } + + $journalItems = []; + foreach ((array)($full['journal_details'] ?? []) as $item) { + $count = intval($item['ref_count'] ?? 0); + $ratio = $count / $total; + if ($ratio <= self::THRESHOLD_SAME_JOURNAL) { + continue; + } + $pReferIds = array_values((array)($item['p_refer_ids'] ?? [])); + $journalItems[] = [ + 'journal_name' => (string)($item['group_name'] ?? ''), + 'cite_count' => $count, + 'cite_ratio' => round($ratio, 4), + 'threshold' => self::THRESHOLD_SAME_JOURNAL, + 'exceeded' => true, + 'p_refer_ids' => $pReferIds, + 'reference_nos' => array_values((array)($item['reference_nos'] ?? [])), + 'references' => $this->buildReferBriefs($pReferIds, $referMap), + ]; + } + + $selfDetails = (array)($full['self_citation_details'] ?? []); + $selfCount = count($selfDetails); + $selfRatio = $selfCount / $total; + $selfItems = []; + foreach ($selfDetails as $item) { + $pReferId = intval($item['p_refer_id'] ?? 0); + $selfItems[] = [ + 'manuscript_author' => (string)($item['matched_manuscript_author'] ?? ''), + 'manuscript_orcid' => (string)($item['matched_orcid'] ?? ''), + 'matched_refer_author' => (string)($item['matched_refer_author'] ?? ''), + 'reference_no' => intval($item['reference_no'] ?? 0), + 'p_refer_id' => $pReferId, + 'reference' => $this->buildReferBriefs([$pReferId], $referMap)[0] ?? null, + ]; + } + + return [ + 'p_article_id' => intval($full['p_article_id'] ?? 0), + 'article_id' => intval($full['article_id'] ?? 0), + 'total_references' => intval($full['total_references'] ?? 0), + 'same_author_stacking' => [ + 'threshold' => self::THRESHOLD_SAME_AUTHOR, + 'exceeded' => !empty($authorItems), + 'items' => $authorItems, + ], + 'same_journal_stacking' => [ + 'threshold' => self::THRESHOLD_SAME_JOURNAL, + 'exceeded' => !empty($journalItems), + 'items' => $journalItems, + ], + 'self_citation' => [ + 'threshold' => self::THRESHOLD_SELF_CITATION, + 'exceeded' => $selfRatio > self::THRESHOLD_SELF_CITATION, + 'cite_count' => $selfCount, + 'cite_ratio' => round($selfRatio, 4), + 'reference_nos' => array_values((array)($full['self_citation_reference_nos'] ?? [])), + 'items' => $selfItems, + 'note' => (string)($full['author_identity_note'] ?? ''), + ], + 'computed_at' => (string)($full['computed_at'] ?? ''), + ]; + } + + /** + * @param int $pArticleId + * @return array + */ + public function compute($pArticleId) + { + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + throw new \InvalidArgumentException('p_article_id is required'); + } + + DbReconnectHelper::release(); + + $articleId = $this->resolveArticleId($pArticleId); + + $refers = Db::name('production_article_refer') + ->field('p_refer_id,index,author,joura,refer_type,refer_doi,doilink,refer_content,refer_frag') + ->where('p_article_id', $pArticleId) + ->where('state', 0) + ->order('index asc') + ->select(); + + $manuscriptAuthors = $this->identity->resolveManuscriptAuthors($pArticleId); + $ambiguousManuscriptNameKeys = $this->buildAmbiguousManuscriptNameKeys($manuscriptAuthors); + $doiCache = []; + $referMap = []; + + $journalBuckets = []; + $authorBuckets = []; + $selfCitationDetails = []; + + foreach ($refers as $refer) { + $refNo = intval($refer['index']) + 1; + $pReferId = intval($refer['p_refer_id']); + $referMap[$pReferId] = $refer; + + $meta = $this->resolveReferMeta($refer, $doiCache); + $joura = (string)$meta['joura']; + $journalKey = $this->normalizeJournalKey($joura); + if ($journalKey !== '') { + if (!isset($journalBuckets[$journalKey])) { + $journalBuckets[$journalKey] = [ + 'group_key' => $journalKey, + 'name' => trim(trim($joura), '.'), + 'count' => 0, + 'reference_nos' => [], + 'p_refer_ids' => [], + ]; + } + $journalBuckets[$journalKey]['count']++; + $journalBuckets[$journalKey]['reference_nos'][] = $refNo; + $journalBuckets[$journalKey]['p_refer_ids'][] = $pReferId; + } + + $referAuthors = $this->resolveReferAuthorsWithMeta($pReferId, $meta); + $this->accumulateAuthorBuckets($authorBuckets, $referAuthors, $refNo, $pReferId); + + $matchedManuscript = $this->matchManuscriptAuthorForSelfCitation( + $referAuthors, + $manuscriptAuthors, + $ambiguousManuscriptNameKeys + ); + if ($matchedManuscript !== null) { + $referAuthorNames = array_map(function ($ra) { + return (string)($ra['name'] ?? ''); + }, $referAuthors); + $selfCitationDetails[] = [ + 'reference_no' => $refNo, + 'p_refer_id' => $pReferId, + 'refer_author' => implode(', ', array_filter($referAuthorNames)), + 'matched_refer_author' => (string)($matchedManuscript['matched_refer_author'] ?? ''), + 'matched_manuscript_author' => (string)($matchedManuscript['display_name'] ?? ''), + 'matched_orcid' => (string)($matchedManuscript['orcid'] ?? ''), + 'match_confidence' => ReferenceAuthorIdentityService::MATCH_FUZZY, + 'meta_source' => (string)$meta['meta_source'], + ]; + } + } + + $journalDetails = $this->formatBucketDetails($journalBuckets, self::DETAIL_JOURNAL); + $authorDetails = $this->formatAuthorBucketDetails($authorBuckets, self::DETAIL_AUTHOR); + $selfDetails = $this->formatSelfCitationDetails($selfCitationDetails); + + $selfCitationRefNos = array_column($selfCitationDetails, 'reference_no'); + + return [ + 'article_id' => $articleId, + 'p_article_id' => $pArticleId, + 'total_references' => count($refers), + 'self_citation_reference_nos' => array_values(array_unique($selfCitationRefNos)), + 'journal_details' => $journalDetails, + 'author_details' => $authorDetails, + 'self_citation_details' => $selfDetails, + 'refer_map' => $referMap, + 'author_identity_note' => '同作者堆叠按 citation_name(空则 display_name)姓名精确匹配,同名即同人;本文出现重名作者(如两位 Lin)时跳过该姓名的自引判定。优先读 t_production_article_refer_author,否则解析 refer.author。', + 'computed_at' => date('Y-m-d H:i:s'), + ]; + } + + private function formatAuthorBucketDetails(array $buckets, $detailType) + { + $list = []; + foreach ($buckets as $bucket) { + $item = [ + 'detail_type' => $detailType, + 'group_key' => (string)($bucket['group_key'] ?? ''), + 'group_name' => (string)($bucket['name'] ?? ''), + 'ref_count' => intval($bucket['count'] ?? 0), + 'reference_nos' => array_values((array)($bucket['reference_nos'] ?? [])), + 'p_refer_ids' => array_values((array)($bucket['p_refer_ids'] ?? [])), + 'match_confidence' => (string)($bucket['match_confidence'] ?? ''), + ]; + if (!empty($bucket['openalex_id'])) { + $item['openalex_id'] = (string)$bucket['openalex_id']; + } + if (!empty($bucket['orcid'])) { + $item['orcid'] = (string)$bucket['orcid']; + } + $list[] = $item; + } + + usort($list, function ($a, $b) { + $cmp = intval($b['ref_count']) <=> intval($a['ref_count']); + if ($cmp !== 0) { + return $cmp; + } + return strcmp((string)$a['group_name'], (string)$b['group_name']); + }); + + return $list; + } + + /** + * @return array + */ + private function resolveReferAuthorsWithMeta($pReferId, array $meta) + { + $pReferId = intval($pReferId); + $list = []; + + if ($pReferId > 0) { + $rows = Db::name('production_article_refer_author') + ->where('p_refer_id', $pReferId) + ->order('author_seq asc, id asc') + ->field('display_name,citation_name,orcid') + ->select(); + foreach ($rows as $row) { + $name = trim((string)($row['citation_name'] ?? '')); + if ($name === '') { + $name = trim((string)($row['display_name'] ?? '')); + } + if ($name === '') { + continue; + } + $list[] = [ + 'name' => $name, + 'orcid' => $this->cleanOrcid($row['orcid'] ?? ''), + ]; + } + } + + if (!empty($list)) { + return $list; + } + + foreach ($this->parseAuthorStringParts((string)($meta['author'] ?? '')) as $name) { + $list[] = [ + 'name' => $name, + 'orcid' => '', + ]; + } + + return $list; + } + + /** + * @return string[] + */ + private function parseAuthorStringParts($authorString) + { + $authorString = trim(trim((string)$authorString), '.'); + if ($authorString === '') { + return []; + } + + $authorString = preg_replace('/\s+et\s+al\.?\s*$/iu', '', $authorString); + $names = []; + foreach (preg_split('/,\s*/u', $authorString) as $part) { + $part = trim($part); + if ($part === '' || preg_match('/^et\s+al\.?$/iu', $part)) { + continue; + } + $names[] = $part; + } + + return $names; + } + + /** + * 本文多位作者姓名归一化后相同(如两位 Lin)则视为歧义,不参与自引匹配 + * + * @return array + */ + private function buildAmbiguousManuscriptNameKeys(array $manuscriptAuthors) + { + $counts = []; + foreach ($manuscriptAuthors as $author) { + $key = $this->normalizeAuthorNameKey((string)($author['display_name'] ?? '')); + if ($key === '') { + continue; + } + if (!isset($counts[$key])) { + $counts[$key] = 0; + } + $counts[$key]++; + } + + $ambiguous = []; + foreach ($counts as $key => $count) { + if ($count > 1) { + $ambiguous[$key] = true; + } + } + + return $ambiguous; + } + + /** + * @param array $referAuthors + * @param array $manuscriptAuthors + * @param array $ambiguousNameKeys + * @return array{display_name:string,orcid:string,matched_refer_author:string}|null + */ + private function matchManuscriptAuthorForSelfCitation(array $referAuthors, array $manuscriptAuthors, array $ambiguousNameKeys) + { + foreach ($referAuthors as $referAuthor) { + $referName = trim((string)($referAuthor['name'] ?? '')); + $referKey = $this->normalizeAuthorNameKey($referName); + if ($referKey === '' || !empty($ambiguousNameKeys[$referKey])) { + continue; + } + + foreach ($manuscriptAuthors as $manuscriptAuthor) { + $manuscriptName = trim((string)($manuscriptAuthor['display_name'] ?? '')); + $manuscriptKey = $this->normalizeAuthorNameKey($manuscriptName); + if ($manuscriptKey !== '' && $manuscriptKey === $referKey) { + return [ + 'display_name' => $manuscriptName, + 'orcid' => trim((string)($manuscriptAuthor['orcid'] ?? '')), + 'matched_refer_author' => $referName, + ]; + } + } + } + + return null; + } + + /** + * @param array $referAuthors + */ + private function accumulateAuthorBuckets(array &$buckets, array $referAuthors, $refNo, $pReferId) + { + if (empty($referAuthors)) { + return; + } + + $seenKeys = []; + foreach ($referAuthors as $author) { + $name = trim((string)($author['name'] ?? '')); + if ($name === '' || preg_match('/^et\s+al\.?$/iu', $name)) { + continue; + } + + $key = $this->normalizeAuthorNameKey($name); + if ($key === '' || isset($seenKeys[$key])) { + continue; + } + $seenKeys[$key] = true; + + if (!isset($buckets[$key])) { + $buckets[$key] = [ + 'group_key' => $key, + 'name' => $name, + 'orcid' => '', + 'match_confidence' => ReferenceAuthorIdentityService::MATCH_FUZZY, + 'count' => 0, + 'reference_nos' => [], + 'p_refer_ids' => [], + ]; + } + + $orcid = trim((string)($author['orcid'] ?? '')); + if ($orcid !== '' && trim((string)($buckets[$key]['orcid'] ?? '')) === '') { + $buckets[$key]['orcid'] = $orcid; + } + + $buckets[$key]['count']++; + $buckets[$key]['reference_nos'][] = $refNo; + $buckets[$key]['p_refer_ids'][] = $pReferId; + } + } + + private function normalizeAuthorNameKey($name) + { + $name = trim(preg_replace('/\.+$/u', '', trim((string)$name))); + $name = preg_replace('/\s+/u', ' ', $name); + if ($name === '') { + return ''; + } + + return mb_strtolower($name); + } + + /** + * @param int[] $pReferIds + * @param array $referMap + * @return array + */ + private function buildReferBriefs(array $pReferIds, array $referMap) + { + $list = []; + foreach ($pReferIds as $pReferId) { + $pReferId = intval($pReferId); + if ($pReferId <= 0 || empty($referMap[$pReferId])) { + continue; + } + $refer = $referMap[$pReferId]; + $list[] = [ + 'p_refer_id' => $pReferId, + 'reference_no' => intval($refer['index'] ?? 0) + 1, + 'refer_text' => $this->referSnippet($refer), + ]; + } + + return $list; + } + + private function loadReferMapByPArticleId($pArticleId) + { + $pArticleId = intval($pArticleId); + if ($pArticleId <= 0) { + return []; + } + + DbReconnectHelper::release(); + $refers = Db::name('production_article_refer') + ->field('p_refer_id,index,author,joura,refer_type,refer_doi,doilink,refer_content,refer_frag') + ->where('p_article_id', $pArticleId) + ->where('state', 0) + ->order('index asc') + ->select(); + + $map = []; + foreach ($refers as $refer) { + $map[intval($refer['p_refer_id'])] = $refer; + } + + return $map; + } + + private function formatBucketDetails(array $buckets, $detailType) + { + $list = []; + foreach ($buckets as $bucket) { + $list[] = [ + 'detail_type' => $detailType, + 'group_key' => (string)($bucket['group_key'] ?? ''), + 'group_name' => (string)($bucket['name'] ?? ''), + 'ref_count' => intval($bucket['count'] ?? 0), + 'reference_nos' => array_values((array)($bucket['reference_nos'] ?? [])), + 'p_refer_ids' => array_values((array)($bucket['p_refer_ids'] ?? [])), + ]; + } + + usort($list, function ($a, $b) { + $cmp = intval($b['ref_count']) <=> intval($a['ref_count']); + if ($cmp !== 0) { + return $cmp; + } + return strcmp((string)$a['group_name'], (string)$b['group_name']); + }); + + return $list; + } + + private function formatSelfCitationDetails(array $items) + { + usort($items, function ($a, $b) { + return intval($a['reference_no']) <=> intval($b['reference_no']); + }); + return $items; + } + + /** + * 合并 refer 行已有字段、Crossref、refer_frag/refer_content 解析结果 + * + * @return array{ + * author:string, + * joura:string, + * author_keys:string[], + * first_author_display:string, + * meta_source:string, + * resolved:bool + * } + */ + private function resolveReferMeta(array $refer, array &$doiCache) + { + $author = trim(trim((string)($refer['author'] ?? '')), '.'); + $joura = trim(trim((string)($refer['joura'] ?? '')), '.'); + $sources = []; + $authorKeys = []; + + if ($author !== '') { + $sources[] = 'local'; + $authorKeys = $this->extractAuthorKeysFromAuthorString($author); + } + + $doi = $this->refUtil->extractDoiFromRefer($refer); + $summary = null; + if ($doi !== '') { + if (!array_key_exists($doi, $doiCache)) { + try { + $doiCache[$doi] = $this->crossref->fetchWorkSummary($doi); + } catch (\Throwable $e) { + $doiCache[$doi] = null; + } + } + $summary = $doiCache[$doi]; + } + + if (is_array($summary)) { + $sources[] = 'crossref'; + if ($joura === '' && trim((string)($summary['joura'] ?? '')) !== '') { + $joura = trim((string)$summary['joura']); + } + $crossrefKeys = $this->authorKeysFromCrossrefMessage($summary['raw'] ?? []); + if ($author === '') { + $citationAuthor = $this->crossref->getAuthorsCitation($summary['raw'] ?? [], 3); + if ($citationAuthor !== '') { + $author = $citationAuthor; + } + $authorKeys = !empty($crossrefKeys) + ? $crossrefKeys + : $this->extractAuthorKeysFromAuthorString($author); + } elseif (!empty($crossrefKeys)) { + $authorKeys = array_values(array_unique(array_merge($authorKeys, $crossrefKeys))); + } + } + + if ($joura === '' || $author === '') { + $fragParsed = $this->parseStructuredReferText($refer); + if (is_array($fragParsed)) { + $sources[] = 'frag'; + if ($joura === '' && trim((string)($fragParsed['joura'] ?? '')) !== '') { + $joura = trim((string)$fragParsed['joura']); + } + if ($author === '' && trim((string)($fragParsed['author'] ?? '')) !== '') { + $author = trim((string)$fragParsed['author']); + $authorKeys = $this->extractAuthorKeysFromAuthorString($author); + } + } + } + + $sources = array_values(array_unique($sources)); + $metaSource = empty($sources) ? 'unresolved' : implode('+', $sources); + $resolved = ($joura !== '' || !empty($authorKeys)); + + return [ + 'author' => $author, + 'joura' => $joura, + 'author_keys' => array_values(array_unique($authorKeys)), + 'first_author_display' => $this->firstAuthorDisplayFromAuthorString($author), + 'meta_source' => $metaSource, + 'resolved' => $resolved, + ]; + } + + /** + * 解析 refer_frag / refer_content 中「作者.标题.期刊.年卷页」四段式结构 + * + * @return array{author:string,joura:string}|null + */ + private function parseStructuredReferText(array $refer) + { + foreach (['refer_frag', 'refer_content'] as $field) { + $text = trim((string)($refer[$field] ?? '')); + if ($text === '') { + continue; + } + $text = preg_replace('/\s+Available at:.*$/is', '', $text); + $text = trim($text, " \t\n\r\0\x0B."); + if ($text === '' || mb_substr_count($text, '.') !== 3) { + continue; + } + + $parts = explode('.', $text); + if (count($parts) < 4) { + continue; + } + + $authorPart = trim((string)$parts[0]); + $journalPart = trim((string)$parts[2]); + if ($authorPart === '' || $journalPart === '') { + continue; + } + + $bj = bekjournal($journalPart); + $joura = formateJournal(trim((string)($bj[0] ?? ''))); + $author = trim(prgeAuthor($authorPart), '.'); + if ($joura === '' && $author === '') { + continue; + } + + return [ + 'author' => $author, + 'joura' => $joura, + ]; + } + + return null; + } + + /** + * @return string[] + */ + private function authorKeysFromCrossrefMessage(array $message) + { + $keys = []; + if (empty($message['author']) || !is_array($message['author'])) { + return $keys; + } + + foreach ($message['author'] as $author) { + if (!is_array($author)) { + continue; + } + $family = trim((string)($author['family'] ?? '')); + $given = trim((string)($author['given'] ?? '')); + if ($family === '' && $given === '') { + $org = trim((string)($author['name'] ?? '')); + if ($org !== '') { + $keys[] = $this->authorKeyFromCitationPart($org); + } + continue; + } + if ($family !== '') { + $keys[] = mb_strtoupper($family) . '|' . $this->givenToInitials($given); + } + } + + return array_values(array_unique(array_filter($keys))); + } + + private function referSnippet(array $refer) + { + foreach (['refer_content', 'refer_frag'] as $field) { + $text = trim((string)($refer[$field] ?? '')); + if ($text !== '') { + $text = preg_replace('/\s+/u', ' ', $text); + return mb_substr($text, 0, 240); + } + } + $doi = trim((string)($refer['refer_doi'] ?? '')); + if ($doi !== '') { + return 'DOI: ' . $doi; + } + return ''; + } + + private function resolveArticleId($pArticleId) + { + $row = Db::name('production_article') + ->field('article_id') + ->where('p_article_id', $pArticleId) + ->whereIn('state', [0, 2]) + ->find(); + + return empty($row['article_id']) ? 0 : intval($row['article_id']); + } + + /** + * @return string[] + */ + private function extractAuthorKeysFromAuthorString($author) + { + $author = trim(trim((string)$author), '.'); + if ($author === '') { + return []; + } + + $keys = []; + foreach (preg_split('/,\s*/u', $author) as $part) { + $part = trim($part); + if ($part === '' || preg_match('/^et\s+al\.?$/iu', $part)) { + continue; + } + $key = $this->authorKeyFromCitationPart($part); + if ($key !== '') { + $keys[] = $key; + } + } + + return array_values(array_unique($keys)); + } + + private function firstAuthorDisplayFromAuthorString($author) + { + $author = trim(trim((string)$author), '.'); + if ($author === '') { + return ''; + } + $parts = preg_split('/,\s*/u', $author); + $first = trim((string)($parts[0] ?? '')); + if (preg_match('/^et\s+al\.?$/iu', $first)) { + return ''; + } + return $first; + } + + private function authorKeyFromCitationPart($part) + { + $part = trim(preg_replace('/\.+$/u', '', trim((string)$part))); + if ($part === '') { + return ''; + } + + $tokens = preg_split('/\s+/u', $part, -1, PREG_SPLIT_NO_EMPTY); + if (count($tokens) === 1) { + return mb_strtoupper($tokens[0]) . '|'; + } + + $last = array_pop($tokens); + if (preg_match('/^[A-Za-z]{1,4}$/u', $last)) { + $family = implode(' ', $tokens); + return mb_strtoupper(preg_replace('/\s+/u', ' ', trim($family))) . '|' . mb_strtoupper($last); + } + + $family = $last; + $initials = ''; + foreach ($tokens as $token) { + $initials .= mb_strtoupper(mb_substr($token, 0, 1)); + } + return mb_strtoupper($family) . '|' . $initials; + } + + private function normalizeJournalKey($joura) + { + $joura = trim(trim((string)$joura), '.'); + if ($joura === '') { + return ''; + } + + $mapped = formateJournal($joura); + $key = mb_strtolower($mapped); + $key = preg_replace('/[^\p{L}\p{N}\s]/u', '', $key); + $key = preg_replace('/\s+/u', ' ', trim($key)); + return $key; + } + + private function givenToInitials($given) + { + $given = trim((string)$given); + if ($given === '') { + return ''; + } + $parts = preg_split('/[\s\-\.]+/u', $given, -1, PREG_SPLIT_NO_EMPTY); + $initials = ''; + foreach ($parts as $part) { + $first = mb_substr($part, 0, 1); + if ($first !== '') { + $initials .= mb_strtoupper($first); + } + } + return $initials; + } + + private function cleanOrcid($orcid) + { + $orcid = trim((string)$orcid); + if ($orcid === '') { + return ''; + } + $orcid = preg_replace('#^https?://orcid\.org/#i', '', $orcid); + return trim($orcid, " \t\n\r\0\x0B/"); + } +} diff --git a/application/common/UnpaywallService.php b/application/common/UnpaywallService.php new file mode 100644 index 00000000..9bb7d294 --- /dev/null +++ b/application/common/UnpaywallService.php @@ -0,0 +1,76 @@ +email = trim((string)Env::get('unpaywall_email', Env::get('pubmed_email', ''))); + } + + /** + * @return string PDF 直链,找不到返回空 + */ + public function findOaPdfUrl($doi) + { + $doi = trim((string)$doi); + if ($doi === '' || $this->email === '') { + return ''; + } + + $url = 'https://api.unpaywall.org/v2/' . rawurlencode($doi) . '?' . http_build_query([ + 'email' => $this->email, + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_HTTPHEADER => ['User-Agent: TMRjournals-Unpaywall/1.0'], + ]); + $raw = curl_exec($ch); + curl_close($ch); + if (!is_string($raw) || $raw === '') { + return ''; + } + + $json = json_decode($raw, true); + if (!is_array($json)) { + return ''; + } + + $best = $json['best_oa_location'] ?? []; + if (!is_array($best)) { + return ''; + } + + foreach (['url_for_pdf', 'url'] as $key) { + $candidate = trim((string)($best[$key] ?? '')); + if ($candidate !== '' && $this->looksLikePdfUrl($candidate)) { + return $candidate; + } + } + + return ''; + } + + private function looksLikePdfUrl($url) + { + if (stripos($url, '.pdf') !== false) { + return true; + } + return (bool)preg_match('#/(pdf|download|content/pdf)#i', $url); + } +} diff --git a/application/common/UserInfoFromFileService.php b/application/common/UserInfoFromFileService.php new file mode 100644 index 00000000..8804e70e --- /dev/null +++ b/application/common/UserInfoFromFileService.php @@ -0,0 +1,358 @@ +llm = $llm ?: new LLMService(); + } + + /** + * 按 user_id 查 user_cv,取最新一条简历解析。 + * + * @param int $userId + * @return array + */ + public function parseFromUserId($userId) + { + $userId = intval($userId); + if ($userId <= 0) { + throw new Exception('user_id 无效'); + } + + $row = Db::name('user_cv') + ->where('user_id', $userId) + ->where('state', 0) + ->order('ctime desc') + ->find(); + if (empty($row) || trim((string) $row['cv']) === '') { + throw new Exception('未找到该用户的简历(user_cv)'); + } + + $cvRel = $this->normalizeCvRelativePath($row['cv']); + $cvUrl = $this->buildCvUrl($cvRel); + $local = $this->resolveLocalCvPath($cvRel); + $temp = false; + $filePath = $local; + + if ($filePath === '' || !is_file($filePath)) { + $filePath = $this->downloadCvToTemp($cvUrl, $cvRel); + $temp = true; + } + + try { + $result = $this->parseFromFile($filePath); + } finally { + if ($temp && is_file($filePath)) { + @unlink($filePath); + } + } + + $result['user_id'] = $userId; + $result['user_cv_id'] = intval($row['user_cv_id'] ?? 0); + $result['cv'] = $cvRel; + $result['cv_url'] = $cvUrl; + + return $result; + } + + /** + * @param string $filePath 服务器本地绝对路径 + * @return array + */ + public function parseFromFile($filePath) + { + $filePath = trim((string) $filePath); + if ($filePath === '' || !is_file($filePath)) { + throw new Exception('文件不存在'); + } + + $text = trim($this->extractFileText($filePath)); + if ($text === '') { + throw new Exception('未能从文件中提取到文本'); + } + + $text = $this->sanitizeUtf8($text); + $maxChars = max(1000, (int) Env::get('promotion.promotion_llm_max_chars', 2500)); + if (mb_strlen($text) > $maxChars) { + $text = mb_substr($text, 0, $maxChars) . "\n...(truncated)"; + } + + $llmResult = $this->parseUserInfoWithLlm($text); + + return [ + 'file' => basename($filePath), + 'text_length' => mb_strlen($text), + 'text_preview' => mb_substr($text, 0, 600), + 'user_info' => $llmResult['user_info'], + 'llm_raw' => $llmResult['raw'], + ]; + } + + private function normalizeCvRelativePath($cv) + { + $cv = trim(str_replace('\\', '/', (string) $cv)); + if ($cv === '') { + return ''; + } + if (preg_match('#^https?://#i', $cv)) { + return $cv; + } + return ltrim($cv, '/'); + } + + private function buildCvUrl($cvRel) + { + if (preg_match('#^https?://#i', $cvRel)) { + return $cvRel; + } + $base = rtrim(trim((string) Env::get('reviewer.cv_base_url', self::CV_BASE_URL_DEFAULT)), '/') . '/'; + return $base . $cvRel; + } + + private function resolveLocalCvPath($cvRel) + { + if (preg_match('#^https?://#i', $cvRel)) { + return ''; + } + $path = ROOT_PATH . 'public' . DS . 'reviewer' . DS . str_replace('/', DS, $cvRel); + return is_file($path) ? $path : ''; + } + + private function downloadCvToTemp($url, $cvRel) + { + $ext = strtolower(pathinfo(parse_url($cvRel, PHP_URL_PATH) ?: $cvRel, PATHINFO_EXTENSION)); + if ($ext === '') { + $ext = 'dat'; + } + + $saveDir = ROOT_PATH . 'runtime' . DS . 'user_file_parse'; + if (!is_dir($saveDir)) { + @mkdir($saveDir, 0755, true); + } + $path = $saveDir . DS . date('YmdHis') . '_' . uniqid('', true) . '.' . $ext; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 15, + CURLOPT_TIMEOUT => 90, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + ]); + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + + if ($body === false || $code < 200 || $code >= 300) { + throw new Exception('下载简历失败(HTTP ' . $code . '): ' . $url . ($err !== '' ? ' ' . $err : '')); + } + if (@file_put_contents($path, $body) === false) { + throw new Exception('保存简历临时文件失败'); + } + return $path; + } + + private function extractFileText($filePath) + { + $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + + if (in_array($ext, ['txt', 'text', 'md', 'csv', 'log'], true)) { + $raw = @file_get_contents($filePath); + return is_string($raw) ? $raw : ''; + } + + if ($ext === 'pdf') { + return $this->extractPdfText($filePath); + } + + if (in_array($ext, ['doc', 'docx'], true)) { + return $this->extractDocxText($filePath); + } + + // 未识别扩展名:先尝试 Word,再尝试 PDF,最后按纯文本读 + $text = $this->tryExtractDocxText($filePath); + if ($text !== '') { + return $text; + } + $text = $this->tryExtractPdfText($filePath); + if ($text !== '') { + return $text; + } + + $raw = @file_get_contents($filePath); + if (!is_string($raw) || $raw === '') { + return ''; + } + if ($this->looksLikeBinary($raw)) { + throw new Exception('不支持的简历文件格式: .' . ($ext !== '' ? $ext : 'unknown')); + } + return $raw; + } + + private function extractPdfText($filePath) + { + $text = $this->tryExtractPdfText($filePath); + if ($text === '') { + throw new Exception('PDF 文本提取失败,请确认已安装 smalot/pdfparser:composer require smalot/pdfparser'); + } + return $text; + } + + private function tryExtractPdfText($filePath) + { + if (!class_exists(PdfParser::class)) { + \think\Log::warning('UserInfoFromFile: smalot/pdfparser not installed'); + return ''; + } + try { + $parser = new PdfParser(); + $pdf = $parser->parseFile($filePath); + $text = $pdf->getText(); + return is_string($text) ? $text : ''; + } catch (\Throwable $e) { + \think\Log::warning('UserInfoFromFile PDF extract: ' . $e->getMessage()); + return ''; + } + } + + private function extractDocxText($filePath) + { + $text = $this->tryExtractDocxText($filePath); + if ($text === '') { + throw new Exception('Word 文档文本提取失败'); + } + return $text; + } + + private function tryExtractDocxText($filePath) + { + if (!function_exists('docxReader')) { + return ''; + } + try { + $text = docxReader($filePath); + return is_string($text) ? $text : ''; + } catch (\Throwable $e) { + \think\Log::warning('UserInfoFromFile docxReader: ' . $e->getMessage()); + return ''; + } + } + + private function looksLikeBinary($raw) + { + if (strpos($raw, "\0") !== false) { + return true; + } + $sample = substr($raw, 0, 4096); + if ($sample === '') { + return false; + } + $nonPrintable = preg_match_all('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $sample); + return $nonPrintable > 10; + } + + private function parseUserInfoWithLlm($text) + { + $system = <<<'PROMPT' +你是学术人员信息抽取助手。根据用户上传的简历、简介或申请表文本,提取可用于期刊投稿/审稿人档案的基本信息。 + +只输出一个 JSON 对象,不要 markdown,不要解释。字段说明: +- realname: 中文姓名;若只有拼音可填拼音;无法判断则空字符串 +- email: 邮箱 +- phone: 手机或电话 +- orcid: ORCID 号(仅数字与连字符) +- technical: 职称/职务(如教授、副主任护师、医学博士) +- field: 主要研究领域,中文,1~3 个词用顿号分隔 +- company: 当前主要任职机构/工作单位(必填优先提取;填完整机构名,如「中南大学湘雅二医院」「暨南大学」,不要只写科室;多机构取最主要或最近一条) +- department: 所在科室/部门/学院(如感染科、药学院) +- country: 国家,默认中国可填「中国」 +- introduction: 100字以内中文简介(职务+机构+方向) + +缺失字段用空字符串 "",不要编造。机构信息请从「工作单位」「任职」「所在单位」「affiliation」等段落提取。 +PROMPT; + + \think\Log::info('UserInfoFromFile user head: ' . mb_substr($text, 0, 600)); + + $raw = $this->llm->requestChat([ + ['role' => 'system', 'content' => $system], + ['role' => 'user', 'content' => "请从以下文本提取用户信息:\n\n" . $text], + ], 0.1); + + if ($raw === null) { + throw new Exception('大模型请求失败,请检查 .env 中 [promotion] PROMOTION_LLM_URL / PROMOTION_LLM_MODEL(与参考文献校对相同)'); + } + + $parsed = $this->llm->parseJsonResponse($raw); + if ($parsed === null) { + throw new Exception('大模型返回无法解析为 JSON:' . mb_substr($raw, 0, 200)); + } + + return [ + 'user_info' => $this->normalizeUserInfo($parsed), + 'raw' => $raw, + ]; + } + + private function normalizeUserInfo(array $parsed) + { + $keys = ['realname', 'email', 'phone', 'orcid', 'technical', 'field', 'company', 'department', 'country', 'introduction']; + $out = []; + foreach ($keys as $k) { + $out[$k] = trim((string) ($parsed[$k] ?? '')); + } + + // 机构:兼容大模型可能返回的别名字段 + if ($out['company'] === '') { + foreach (['机构', '单位', 'institution', 'affiliation', 'organization', 'org', '工作单位', '任职单位'] as $alias) { + $val = trim((string) ($parsed[$alias] ?? '')); + if ($val !== '') { + $out['company'] = $val; + break; + } + } + } + + if ($out['orcid'] !== '') { + $out['orcid'] = preg_replace('/\s+/', '', $out['orcid']); + } + + // 对外同时返回 institution(与 company 同值) + $out['institution'] = $out['company']; + + return $out; + } + + private function sanitizeUtf8($text) + { + $text = (string) $text; + if (function_exists('mb_convert_encoding')) { + $text = mb_convert_encoding($text, 'UTF-8', 'UTF-8'); + } + if (function_exists('iconv')) { + $fixed = @iconv('UTF-8', 'UTF-8//IGNORE', $text); + if (is_string($fixed)) { + $text = $fixed; + } + } + return $text; + } +} diff --git a/application/common/mq/RabbitMqConfig.php b/application/common/mq/RabbitMqConfig.php index df30aa5e..64f8e97e 100644 --- a/application/common/mq/RabbitMqConfig.php +++ b/application/common/mq/RabbitMqConfig.php @@ -21,4 +21,10 @@ class RabbitMqConfig $rc = self::get('reference_check', []); return is_array($rc) ? $rc : []; } + + public static function aiWritingRisk() + { + $cfg = self::get('ai_writing_risk', []); + return is_array($cfg) ? $cfg : []; + } } diff --git a/application/common/mq/ReferenceCheckArticleWorker.php b/application/common/mq/ReferenceCheckArticleWorker.php index 5c2701db..7724ec15 100644 --- a/application/common/mq/ReferenceCheckArticleWorker.php +++ b/application/common/mq/ReferenceCheckArticleWorker.php @@ -49,7 +49,11 @@ class ReferenceCheckArticleWorker if (!$this->claimBatch($batchId)) { $batch = $this->getBatch($batchId); - if (empty($batch) || intval($batch['batch_status']) === self::BATCH_DONE) { + // 已被其他消费者领取或已结束,当前消息直接跳过,避免同批次并发重复执行 + if (empty($batch) + || intval($batch['batch_status']) === self::BATCH_RUNNING + || intval($batch['batch_status']) === self::BATCH_DONE + || intval($batch['batch_status']) === self::BATCH_PARTIAL_FAILED) { return; } } @@ -100,7 +104,8 @@ class ReferenceCheckArticleWorker $now = date('Y-m-d H:i:s'); $affected = Db::name('article_reference_relevance_check_batch') ->where('id', intval($batchId)) - ->whereIn('batch_status', [self::BATCH_WAITING, self::BATCH_RUNNING]) + // 只允许 WAITING -> RUNNING,禁止已 RUNNING 的批次被重复 claim + ->where('batch_status', self::BATCH_WAITING) ->update([ 'batch_status' => self::BATCH_RUNNING, 'updated_at' => $now, @@ -145,12 +150,14 @@ class ReferenceCheckArticleWorker } catch (\Exception $e) { $this->svc->log('ReferenceCheckArticleWorker check_id=' . $checkId . ' err=' . $e->getMessage()); DbReconnectHelper::ensure(); - if ($retryCount < ReferenceRelevanceCheckService::QUEUE_MAX_RETRY) { - $this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_PENDING, $retryCount + 1); - return $this->processOneRow($checkId, array_merge($row, ['retry_count' => $retryCount + 1]), $skipLiteratureFetch); - } try { $fresh = Db::name('article_reference_relevance_check_result')->where('id', intval($checkId))->find(); + if (!empty($fresh) && intval($fresh['status']) === ReferenceRelevanceCheckService::RECORD_FAILED) { + if (intval($fresh['queue_status']) !== ReferenceRelevanceCheckService::QUEUE_FAILED) { + $this->svc->markQueueRuntime($checkId, ReferenceRelevanceCheckService::QUEUE_FAILED, $retryCount); + } + return 'failed'; + } $groupRows = !empty($fresh) ? $this->svc->findCitationGroupRowsForWorker($fresh) : []; if (!empty($groupRows)) { $this->svc->failGroupWithQueue($groupRows, $e->getMessage(), $retryCount); diff --git a/application/common/service/ReferenceRelevanceLlmService.php b/application/common/service/ReferenceRelevanceLlmService.php index 975ead5f..8a07d033 100644 --- a/application/common/service/ReferenceRelevanceLlmService.php +++ b/application/common/service/ReferenceRelevanceLlmService.php @@ -18,6 +18,7 @@ class ReferenceRelevanceLlmService private $maxLocalContextChars; private $maxReferChars; private $maxAbstractChars; + private $maxTokens; public function __construct() { @@ -30,12 +31,13 @@ class ReferenceRelevanceLlmService $this->maxLocalContextChars = max(600, intval(Env::get('promotion.relevance_llm_max_local_context_chars', 1800))); $this->maxReferChars = max(1500, intval(Env::get('promotion.relevance_llm_max_refer_chars', 3500))); $this->maxAbstractChars = max(1500, intval(Env::get('promotion.relevance_llm_max_abstract_chars', 3500))); + $this->maxTokens = max(4096, intval(Env::get('promotion.relevance_llm_max_tokens', 0))); } /** - * @return array{results:array,request_failed?:bool,reason?:string} + * @return array{results:array,claims?:array,combined_relevance_score?:float,combined_reason?:string,request_failed?:bool,reason?:string} */ - public function checkRelevance($sectionText, $localContext, $referText, $abstractText = '', $citeGroupRefs = '') + public function checkRelevance($sectionText, $localContext, $referText, $abstractText = '', $citeGroupRefs = '', array $referTypeMap = []) { $fallback = [ 'results' => [], @@ -67,12 +69,14 @@ class ReferenceRelevanceLlmService $abstractText = mb_substr($abstractText, 0, $this->maxAbstractChars); } + $refCount = $this->countCiteGroupRefs($citeGroupRefs); $payload = [ 'model' => $this->model, 'temperature' => 0, + 'max_tokens' => $this->resolveMaxTokens($refCount), 'messages' => [ ['role' => 'system', 'content' => $this->buildSystemPrompt()], - ['role' => 'user', 'content' => $this->buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs)], + ['role' => 'user', 'content' => $this->buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount, $referTypeMap)], ], ]; @@ -84,15 +88,89 @@ class ReferenceRelevanceLlmService $parsed = $this->parseJson($content); if ($parsed === null) { - return array_merge($fallback, ['reason' => 'LLM response JSON parse failed']); + $saved = $this->saveBadJsonResponse($content, [ + 'cite_group_refs' => $citeGroupRefs, + 'section_chars' => mb_strlen($sectionText), + 'refer_chars' => mb_strlen($referText), + ]); + $truncHint = $this->isTruncatedResponse($content) ? ' (response likely truncated)' : ''; + $savedHint = $saved !== '' ? '; saved=' . $saved : ''; + return array_merge($fallback, ['reason' => 'LLM response JSON parse failed' . $truncHint . $savedHint]); } - $results = $this->normalizeResults($parsed, $citeGroupRefs, $localContext, $referText, $abstractText); + $normalized = $this->normalizeResults($parsed, $citeGroupRefs, $localContext, $referText, $abstractText); + $results = isset($normalized['results']) && is_array($normalized['results']) ? $normalized['results'] : []; + $combinedScore = floatval(isset($normalized['combined_relevance_score']) ? $normalized['combined_relevance_score'] : 0); + $combinedReason = (string)(isset($normalized['combined_reason']) ? $normalized['combined_reason'] : ''); + $claims = isset($normalized['claims']) && is_array($normalized['claims']) ? $normalized['claims'] : []; + if (!empty($results) && $refCount > 1 && count($results) < $refCount) { + $beforeFill = count($results); + $results = $this->fillMissingGroupResults($results, $citeGroupRefs, $refCount, $combinedScore, $combinedReason); + if (count($results) > $beforeFill) { + \think\Log::warning(sprintf( + 'ReferenceRelevanceLlm: filled %d missing results for cite_group_refs=%s', + count($results) - $beforeFill, + $citeGroupRefs + )); + } + } if (empty($results)) { - return array_merge($fallback, ['reason' => 'LLM returned empty or invalid results']); + $rawCount = 0; + if (isset($parsed['results']) && is_array($parsed['results'])) { + $rawCount = count($parsed['results']); + } elseif (isset($parsed['reference_no']) || isset($parsed['relevance_score'])) { + $rawCount = 1; + } + $saved = $this->saveBadJsonResponse($content, [ + 'cite_group_refs' => $citeGroupRefs, + 'section_chars' => mb_strlen($sectionText), + 'refer_chars' => mb_strlen($referText), + 'raw_count' => $rawCount, + 'kept_count' => 0, + ]); + $savedHint = $saved !== '' ? '; saved=' . $saved : ''; + $detail = $rawCount > 0 + ? sprintf(' (parsed %d rows, kept 0%s)', $rawCount, $savedHint) + : $savedHint; + return array_merge($fallback, ['reason' => 'LLM returned empty or invalid results' . $detail]); + } + if ($refCount > 1 && count($results) < $refCount) { + $saved = $this->saveBadJsonResponse($content, [ + 'cite_group_refs' => $citeGroupRefs, + 'section_chars' => mb_strlen($sectionText), + 'refer_chars' => mb_strlen($referText), + 'partial_count' => count($results), + 'expected_count' => $refCount, + ]); + $savedHint = $saved !== '' ? '; saved=' . $saved : ''; + return array_merge($fallback, [ + 'reason' => sprintf('LLM returned %d/%d results (likely truncated)%s', count($results), $refCount, $savedHint), + ]); } - return ['results' => $results]; + if (count($results) > 1) { + $bands = $this->getScoreBands(); + $adjustedCombined = $this->enforceCombinedAgainstSingles($results, $combinedScore, $bands); + if (abs($adjustedCombined - $combinedScore) > 0.001) { + $combinedScore = $adjustedCombined; + $combinedReason = $this->fallbackReasonFromScore( + $combinedScore, + $this->levelFromScore($combinedScore) + ); + \think\Log::warning(sprintf( + 'ReferenceRelevanceLlm: combined score clamped to %.2f for cite_group_refs=%s', + $combinedScore, + $citeGroupRefs + )); + } + } + + return [ + 'results' => $results, + 'claims' => $claims, + 'combined_relevance_score' => $combinedScore, + 'combined_reason' => $combinedReason, + ]; } private function buildSystemPrompt() @@ -114,7 +192,8 @@ class ReferenceRelevanceLlmService - 机制研究、分子通路、细胞增殖/迁移、血管生成等**原始研究** → 单条通常 **0.45 或更低**,`is_relevant=0`,`minimal_relevance` - 不得因摘要提到 colorectal cancer 就给 0.92 - 仅当文献为流行病学综述/公共卫生研究,或明确讨论发病率、死亡率、疾病负担时,单条才可 **0.85~0.92** -4. **联合分写在 combined_relevance_score**,与单条分必须可分离(例如 [1,2] 时文献1=0.45、文献2=0.92、联合=0.92)。 +4. **联合分写在顶层 combined_relevance_score / combined_reason**,与单条分必须可分离(例如 [1,2] 时文献1=0.45、文献2=0.92、联合=0.92);**禁止在 results 各条中重复 combined_***。 + **联合分不得与单条分矛盾**:若全部单条均为 0.25,联合必须为 0.25;若全部单条均 ≤0.45,联合不得 >0.45;联合分不得远高于最高单条分(禁止「单条全弱、联合高分」)。 5. **「来源/化学分类」型句子**(naturally occurring、pentacyclic triterpenoid、found in fruits/vegetables/medicinal plants、并列举具体植物学名): - 先判文献类型:来源综述 / 生物活性综述 最适合;**抗癌治疗综述**对「来源分布」claim 通常仅 **0.65** - 单篇可差异化打分(如 0.92 / 0.92 / 0.65),**不得**因联合而三篇都给高分 @@ -143,38 +222,128 @@ class ReferenceRelevanceLlmService - **单一药物 / 单一成分 / 单一通路的专题综述**(如「某化合物抗某癌:A review」),即使同病、同大方向,也只是专题视角、并非为该总论 claim 做系统总结 → 通常 **partially_related(0.72~0.78)**,**不得给 0.85+** - **单基因 / 单通路的机制原始研究**对纯流行病学负担 claim → 仍按规则 3 给 **0.45** - 判断要点:文献类型是否「为该总论 claim 本身做系统综述/总论」;仅同病同方向、或只支撑整段中某一两句(如「需要更安全的新策略」),不足以进入 highly_related +12. **图书/教材参考文献(无 DOI、无摘要)**: + - 识别特征:ISBN、版次(3rd ed.)、出版社+年份、无期刊名/无 DOI + - **不得因缺少外部摘要就默认 0.25**;须从**书名、副标题、作者专业领域、出版社、版次**判断文献类型 + - 若书名/副标题明确为某学科理论/模型/知识体系的**分析、评价、教材、手册**(书名/副标题含"理论/模型/知识/原理/导论/手册/概论"等指示词),且引用处 claim 为该学科理论功能/概念框架/实践指导概述 → 可按**教材/理论专著**匹配,通常 **0.85~0.98** + - 若仅有书目信息、无法确认主题粒度,给 **0.65~0.78**,不得轻易 0.25 +13. **学科理论/概念「多功能并列」总论句**(一句并列列举某类理论/概念/方法的多项功能,例如"提供概念框架、描述现象、组织专业推理、解释情境、指导设计与实践、为行动提供依据",或"并非历史遗产而是当前仍具价值"等总括表述): + - 判分核心 = **文献系统覆盖并列功能的完整度 + 主题层级是否为「整个学科理论/概念体系」**,据此在 highly_related 内做档内区分,不得一律 0.98 也不得因是教材就压到 0.65 + - **综合性理论经典教材 / 学科知识体系专著**(对该学科理论做全面分析、评价、体系化阐述),系统覆盖全部并列功能 → **0.98** + - 教材/专著但**聚焦子类**(某一子类理论、单一模型、单一学派/作者)或个别并列功能非其重点 → **0.92**(仍 highly_related,因层级/覆盖略窄不给 0.98) + - **综述**强调该领域理论当代价值/发展/未来方向,但非系统罗列全部功能 → **0.85** + - **哲学 / 元层(元范式、上位框架)/ 学科本体论论文**:概念层级高于「具体功能」,通常覆盖"仍具价值、提供概念资源",但对"描述现象/组织推理/指导设计/提供依据"等具体功能仅部分覆盖 → **0.78(partially_related)**,不给 0.92 + - 仅间接沾边、层级或主语明显不符 → **≤0.65** + - **联合分**:多篇经典教材已系统覆盖全部并列功能时,联合可 **0.98**,不得因个别子类/哲学文献偏窄而压低整体 +14. **分值必须与你写出的 Claim 覆盖自洽(写完 reason 后必须自检;通用规则,适用所有文献类型)**: + - 先按你写出的覆盖标注计算「覆盖比例」= (完整✔数 + 0.5×部分数) / Claim总数,再据此定分: + - 覆盖比例 ≥0.9 且无✘ → **0.92~0.98**(系统/全部覆盖=0.98) + - ≥0.72 → **0.92**;≥0.58 → **0.85**;≥0.45 → **0.78**;≥0.28 → **0.65**;≥0.15 → **0.45**;几乎全✘/无覆盖 → 0.25/0.15 + - **0.25/0.15 仅用于「主语不一致」或「几乎完全无覆盖(无任何✔/部分)」**。只要**主语一致**且 reason 写出**任意 ✔ 或部分**,就**禁止**给 0.25,至少 **0.45**。 + - **流行病学/患病率数据型 Claim** 引用诊疗指南、治疗进展说明、原始机制研究等**非流行病学文献**时:即使主语一致(同病),通常 **0.45**(同领域但证据类型不匹配),**不是 0.25**。 + - **仅 1 个 Claim 且 A✔ + 主语一致 + 类型完全匹配**(如流行病学权威数据明确支撑患病率/发病率 claim)→ **0.92~0.98**,**严禁 0.25** + - **以下均不是给低分的正当理由**(属于常见误判,须避免):"偏哲学/元范式""侧重子类/知识发展/发展史""非最全面/非全功能""某功能非全书重点""偏教育应用"——这些至多按覆盖比例降档(如 0.78/0.85),**不得**据此判 0.25。 + - 正当低分只来自:主语/研究对象层级不对、证据层级不足(计算预测/讨论转引/提取物非单体)、文献类型完全不适配、或覆盖比例确实 <0.28。 + - 若所写覆盖与分值矛盾,**以覆盖比例为准修正分值**,再输出 +15. **学科理论发展/关键议题/未来方向型综述**(叙述+文献回顾,讨论某学科理论演变、贡献、挑战、知识结构与实践知识未来): + - 摘要若回顾理论贡献、提出知识发展结构/框架、展望基于理论的实践知识 → **A✔ B✔ 通常成立**;与"实践/研究/知识/推理"相关的 Claim 至少 2 项应为 ✔或部分,**不得因非教科书式逐条罗列就全部标 ✘** + - 典型分值:**0.85**(强调当代价值与发展方向,覆盖 A/B 及若干实践相关 Claim);系统阐述知识结构与多数功能 → **0.92** + - **禁止**将此类综述因"非系统介绍全部功能"判为 0.25 +16. **哲学/元范式/学科本体论论文**(讨论 metaparadigm、disciplinary ontology、学科探究基础,而非专业价值观或实践技能): + - 当引用处 Claim 涉及**学科知识/理论素养基础/理论传统**时:A(学科知识/disciplinary knowledge)通常 **✔或部分**;B(护理理论)**部分/间接**;C(专业价值观/实践技能类)常 **✘** → 典型 **0.78**,**不是 0.25** + - **禁止**因「侧重本体论/元范式/非专业价值」就把 A 标 ✘ 或给 0.25;"哲学/元层"只意味着不给 0.92,应给 **0.65~0.78** +17. **「多事实背景综括句」封顶(通用,适用所有文献类型;防止命中单一主题即给高分)**: + 当引用处在一句/一段内并列多个独立事实断言(典型:疾病进展/现状 + 某人群结局 + 与其他人群/疾病的比较 + 具体成因列举 + 流行病学数据),**必须把每个断言拆成独立 Claim**,尤其下列三类不得并入其它 Claim、不得省略: + - **比较型 Claim**("A 高于/低于/优于/区别于 B""显著低于其他……"):仅当文献**明确提供该对比数据或对比结论**时才可标 ✔;文献只研究 A 本身、未与 B 比较 → 该项标 **✘**(不得标"部分") + - **成因/因果型 Claim**("因……导致""原因包括……"):仅当文献**明确论证该因果或列举相同成因**时才 ✔;文献仅涉及相关变量但未确立该因果 → **部分或✘** + - **进展/现状型 Claim**("取得较大进展""诊疗水平提高"等):仅当文献**正面陈述该进展/现状**时才 ✔ + - **单篇原始研究**(非系统综述、非流行病学/公共卫生研究)支撑此类多事实综括句:即使主语一致、命中其中 1 项主题,通常也仅 **0.65~0.78**,**不得给 0.85+**(单篇原始研究难以覆盖背景综括句的全部并列断言) + - 按硬规则14计算覆盖比例后,**若含 ≥2 项明确 ✘,单条不得 ≥0.85**;含 ≥1 项比较型/成因型 ✘ 时,不得因其余 Claim ✔ 而升入 highly_related + - **典型示例(多事实背景句 + 单篇原始研究)**:引用处「HF诊疗取得进展 + 老年HF患者QOL较低 + 低于其他慢病老人 + 因长疗程/并发症/急性加重」,文献为 Lee & Song 2015 SEM 症状管理与QOL研究: + - Claim 应拆为:A诊疗进展✘、B HF患者QOL相关✔或部分、C 低于其他慢病比较✘、D 具体成因列举部分或✘ + - 覆盖约 35%~45% → **0.65(partially_related)**;**不得**因「都研究HF+QOL」给 0.92,**不得**因有多项✘就给 0.25 ================================================== 【一、必须先拆解 claim】 -从【本引用位置附近上下文】中提炼最小主张单元(Claim A, Claim B…),**不要**把整句笼统归为「大概讲抗癌」。例如: -- **主语/研究对象**(化合物单体 vs 植物提取物 vs 其他物种;是否「X has been demonstrated」) +从【本引用位置附近上下文】中提炼最小主张单元(Claim A, Claim B…),**不要**把整句笼统归为「大概讲同一领域」。 +拆解结果写入 JSON 顶层 **`claims` 字段**(键 A/B/C…,值为一行中文具体内容),本引用组只写一次;reason/combined_reason 中**禁止重复 Claim 全文**,仅用字母指代。 +例如可拆解维度: +- **主语/研究对象**(总论对象 vs 子类专题 vs 上位概念;化合物单体 vs 混合物/提取物;是否「X has been demonstrated」) - **证据语气与层级**(demonstrated / mechanistically vs predict / suggest;本文结果 vs 讨论转引) -- **claim 主题粒度**:是否为疾病总论型(流行病学负担 / 治疗现状与局限 / 基因组异质性 / 单靶点受限 / 亟需新策略);若是,要求「总体综述 / 分子病理 / 精准肿瘤学 / 耐药综述」类来源,单一药物专题综述只算 partially_related +- **claim 主题粒度**:是否为总论型 claim(流行病学负担 / 治疗现状与局限 / 学科功能概述 / 理论作用概述等);子类专题文献不得因同领域就给满分 - 疾病流行病学(高发、死亡率) +- **比较型断言**(A 高于/低于/优于/区别于 B、显著低于其他人群/疾病等,须单独成 Claim) +- **成因/因果型断言**("因……导致""原因包括……",须单独成 Claim) +- **进展/现状型断言**("取得较大进展""诊疗水平提高"等,须单独成 Claim) - **点名通路/分子机制**(PI3K/AKT、MAPK、NF-κB 等,须逐项) - **点名功能结局**(抑制增殖、凋亡、血管生成、炎症信号等,须逐项) +- **概念/理论/方法功能**(定义、分类、机制、推理、实践指导、理论依据等,须逐项) - 治疗/干预现状 - **化合物化学类别**(如 pentacyclic triterpenoid) - **天然来源分布**(fruits / vegetables / medicinal plants) - **具体列举项**(植物学名、药名、基因名等,须逐项核对) +================================================== +【标准校对流程(每篇文献必须按此顺序推理,再写入 reason)】 +对 cite_group_refs 中**每一篇**文献,严格按以下**六步**判断,不得跳步: + +**步骤① 提取 Claim**(写入顶层 `claims`;多事实背景句须拆出比较型/成因型/进展型/数据型独立 Claim) + +**步骤② 提取文献证据**(从摘要/清洗内容提取研究对象、变量、结论;不得仅凭题名臆测) + +**步骤③ Claim Mapping**(逐项 ✔ / 部分 / ✘;比较型无对比数据一律 ✘) + +**步骤④ 计算覆盖率**((✔数 + 0.5×部分数) / Claim总数) + +**步骤⑤ 判断支持类型**(整句直接支持 / 部分支持 / 仅同领域) + +**步骤⑥ 输出分值与理由**(分值与覆盖自洽;写明支持项与缺失项) + +在 reason 中须体现:文献类型与**步骤①③**主语是否一致;**步骤③ Claim 覆盖**(字母+✔/部分/✘);**步骤③**类型是否匹配;**步骤⑥**分值理由。 + +**步骤① 主语是否一致(并入 Claim Mapping 前必判)** +- 正文 claim 的主语/核心对象是什么? +- 文献的核心研究对象是什么?(总论对象 / 子类专题 / 上位概念 / 单一机制 / 单一干预等) +- 主语层级不一致时,即使同领域也不得给 0.85+ + +**步骤② Claim 覆盖** +- 逐步核对 Claim A、B、C… 是否覆盖(✔ 完全覆盖 / 部分 / 不覆盖) +- 不得因文献「同领域」就默认全部 ✔ +- 正文逐条列举的功能、通路、结局、理论作用,须逐项核对 + +**步骤③ 文献类型是否匹配** +- 先判文献类型:教材 / 图书 / 综述 / 原始研究 / 哲学论文 / 专题综述 / 流行病学研究 等 +- **图书/教材**:无摘要时据书名、副标题、ISBN、出版社判断,不得因缺摘要直接判 unrelated +- **哲学 / 元范式 / 本体论论文**:概念层级高于「具体理论功能」,对"实践功能"类并列 claim 通常仅部分覆盖 → partially_related(见硬规则13),不因同学科给 0.92 +- **综述 vs 综合性教材**:面对"多功能并列总论句",系统性综合教材>聚焦子类教材>综述>哲学论文(见硬规则13的档内区分) +- 判断该类型是否适合支撑**本引用处具体 claim**(不是仅适合该学科) + +**步骤④ 分值理由(为何是此分而非更高/更低)** +- 说明与上一档或下一档分值的差异原因 +- 常见降分情形:主语收窄为子类、概念层级偏高/偏低、专题视角非系统总论、仅覆盖部分 claim、证据层级不足 +- 常见升分情形:系统综述/经典文献完整覆盖全部 claim、类型与引用用途完全匹配、本文自身证据直接支撑 + +**reason 写法(中文,默认 220 字以内;≥5 篇联合时按下方指令压缩)** +Claim 的具体内容**只写在 JSON 顶层 `claims` 字段**,reason **禁止重复写出 Claim 全文**。 +必须包含:①文献类型与主语是否一致;②**Claim 覆盖标注**(仅用字母+✔/部分/✘,如 "A✔ B✔ C部分 D✘");③类型是否匹配引用用途;④为何是此分而非上/下一档。 +**写完后自检(硬规则14):全部/绝大多数 Claim ✔ 且类型匹配 ⇒ 0.92~0.98,绝不可给 ≤0.45;若分值与覆盖矛盾,以覆盖为准修正分值。** +高分示例:「教材,主语一致。Claim覆盖:A✔ B✔ C✔ D✔ E✔。类型完全匹配、系统阐述各项功能。全部覆盖且类型匹配,故 0.98。」 +降分示例:「综述,主语一致。Claim覆盖:A✔ B✔ C部分 D✘。类型部分匹配。因缺 D 且 C 仅背景,未达 0.92,故 0.85。」 +部分支持示例:「原始研究,主语一致(HF患者)。Claim覆盖:A✘ B✔ C✘ D部分。覆盖率约38%。类型不匹配整句(无诊疗进展/无跨病种比较)。支持QOL主题但不支持比较与进展,故 0.65。」 +同领域错类型示例:「指南更新说明,主语一致。Claim覆盖:A✘ B部分。无患病率数据。流行病学claim用指南支撑,故 0.45。」 + +**combined_reason 写法(联合引用,中文,默认 300 字以内;≥5 篇联合时按下方指令压缩)** +Claim 具体内容见顶层 `claims`,combined_reason **只用字母指代**(如 A/B/C),说明各篇分工、整句覆盖完整度、联合分升降依据。 +格式示例:「[1][3] 系统覆盖 A/B/C/D,[2] 补充 E,[4] 仅覆盖 A/B。整句各项 Claim 均获多篇互补覆盖、无明显缺口;联合高于任一单篇因分工互补,故联合 X.XX。」 + ================================================== 【二、逐篇文献单独判断(每条 result 对应一个 reference_no)】 对 cite_group_refs 中的每一篇文献,单独输出: - 该文献与引用处哪些 claim 主题相关、哪些不相关(含具体列举项是否覆盖) - 文献类型是否匹配引用用途(来源综述 / 生物活性综述 / 机制研究 / 流行病学综述 / 抗癌治疗综述等) - relevance_score:只能使用 0.98 / 0.92 / 0.85 / 0.78 / 0.65 / 0.45 / 0.25 / 0.15 -- relevance_level:highly_related | partially_related | weakly_related | unrelated - is_relevant:score>=0.65 为 1,否则 0 -- relevance_role: - - primary_relevance:该文献是引用处主题的主要相关来源 - - supplementary_relevance:部分相关、补充性 - - minimal_relevance:仅边缘/背景沾边 - - no_meaningful_relevance:与引用处核心表述基本无关 -- reason:中英双语结论,格式固定为两行: - 【中文】(中文结论,须写明:①文献类型与**核心研究对象** ②**本文自身证据**覆盖了哪些 claim / 哪些未覆盖 ③主语/claim 不匹配须明确写出 ④为何此分值) - 【English】(与中文对应的英文结论,语义一致) -- reason_en:仅英文结论(与 reason 中【English】段相同,勿留空) +- reason:仅中文结论,禁止 reason_en、【English】等英文字段;默认每条约 220 字以内(≥5 篇联合时按下方指令压缩),须体现步骤①主语→②Claim覆盖(仅字母+✔/部分/✘,具体内容见顶层 claims)→③类型匹配→④分值理由;分值须与覆盖自洽(硬规则14) 主语/层级不对 → 单条 **0.45**,不得因讨论提及相同通路给 0.78: 引用处 claim 为「化合物 X 经 PI3K/AKT 等机制 demonstrated…」,文献为其他植物提取物或计算预测、仅在讨论转引他人 X 机制 → 0.45,weakly_related,is_relevant=0。 @@ -183,20 +352,19 @@ class ReferenceRelevanceLlmService 文献为 CRC 机制研究,引用处 claim 为全球高发/死亡率,文献无流行病学数据 → 0.45,minimal_relevance,is_relevant=0。 ================================================== -【三、联合引用 combined_*(同一 cite_group_refs 内各行必须一致)】 -当 cite_group_refs 为 "1,2" 等多篇时,除逐篇判断外,必须给出引用组整体结论: +【三、联合引用 combined_*(写在 JSON 顶层,只出现一次)】 +当 cite_group_refs 为 "1,2" 等多篇时,除逐篇判断外,必须在 JSON **顶层**给出引用组整体结论(不要写入 results 各条): - 这些文献合起来,是否足以支撑/匹配该引用位置的整体表述? - combined_relevance_score:八档固定分值之一,**不是单条平均分** - 若一篇已强相关、其余仅弱补充,联合分可接近主相关文献,但**不必等于最高单条分** - 若原句含具体列举项(学名等)且材料未逐一核实,联合分通常 **0.85**,不给 0.98 - 若核心 claim 无任何文献明确覆盖,联合分不能虚高 - 多篇联合仍缺主语对齐、缺原句点名通路/结局、或主要靠讨论转引 → 联合分通常 **≤0.45~0.65**,不得因单篇讨论出现相同关键词给到 0.78+ -- combined_is_relevant:combined_relevance_score>=0.65 为 1 -- combined_relevance_level:与 combined 分数对应的等级 -- combined_reason:中英双语综合结论,格式同 reason(【中文】/【English】),说明各文献分工及最终分值理由 -- combined_reason_en:仅英文综合结论(与 combined_reason 中【English】段相同) +- combined_reason:仅中文综合结论(禁止 combined_reason_en、【English】),只写一次;须说明各篇如何分工互补、整句 claim 覆盖完整度、联合分为何高于/低于最高单条分 -单条引用时:combined_* 与单条一致;combined_reason / combined_reason_en 可与 reason / reason_en 相同。 +单条引用时:顶层 combined_* 与单条一致;combined_reason 可与 reason 相同。 + +**联合引用 ≥5 篇时**:优先保证 results 条数完整;为防截断每条 reason ≤120 字、顶层 combined_reason ≤180 字,但仍须**点名各 Claim 覆盖**(哪几条✔/部分/✘),不可只写"部分相关"。 ================================================== 【四、评分与等级对照】 @@ -213,34 +381,45 @@ class ReferenceRelevanceLlmService ================================================== 【五、输出 JSON(仅 JSON,无 markdown)】 +先拆解 Claim,在顶层 `claims` 写出各条具体内容(键为 A/B/C…,值为中文一句话);再逐篇输出 results。 { + "cite_group_refs": "1,2", + "claims": { + "A": "该理论/概念仍具当代价值而非仅历史遗产", + "B": "提供概念框架或概念资源", + "C": "帮助描述研究/护理/临床现象", + "D": "组织专业推理或解释情境", + "E": "指导实践设计与为行动提供依据" + }, + "combined_relevance_score": 0.92, + "combined_reason": "[1] 系统覆盖 A/B/C,[2] 补充 D/E。整句各项 Claim 由两篇互补覆盖、无明显缺口;联合略高于单篇因分工互补,故联合 0.92。", "results": [ { "reference_no": 1, - "cite_group_refs": "1,2", "is_relevant": 0, "relevance_score": 0.45, - "relevance_level": "weakly_related", - "relevance_role": "minimal_relevance", - "reason": "【中文】中文单条结论\n【English】English single-reference conclusion", - "reason_en": "English single-reference conclusion", - "combined_is_relevant": 1, - "combined_relevance_score": 0.92, - "combined_relevance_level": "highly_related", - "combined_reason": "【中文】中文联合结论\n【English】English combined conclusion", - "combined_reason_en": "English combined conclusion" + "reason": "系统综述,主语一致。Claim覆盖:A✔ B✔ C部分 D✘。类型部分匹配。因缺 D 且 C 仅背景,未达 0.65,故 0.45。", + "author_comment": "该文献与正文主张仅部分匹配,仍缺关键论点支撑。建议补充更直接覆盖核心结论的参考文献。" }, { "reference_no": 2, - "cite_group_refs": "1,2", - ... + "is_relevant": 1, + "relevance_score": 0.92, + "reason": "综述,主语一致。Claim覆盖:A✔ B✔ D✔ E部分。类型完全匹配。多数关键要素明确覆盖、仅 E 略间接,故 0.92。", + "author_comment": "" } ] } + +**顶层 claims**:本引用组共用,只写一次;键为 A/B/C…(按实际拆解数量),值为各 Claim 中文具体内容。 +**results 每条仅含**:reference_no、is_relevant、relevance_score、reason、author_comment。 +`author_comment` 规则:当 `relevance_score <= 0.65` 时必须生成(中文、礼貌、适合展示给作者、可执行,≤90字);当 `relevance_score > 0.65` 时必须返回空字符串 `""`。 +注意:`author_comment` 禁止出现 A/B/C/D、✔/✘、"Claim覆盖"、具体分值(如 0.65)等技术表达,需改写为作者易读总结。 +**禁止**在 results 各条中写 combined_relevance_score、combined_reason、cite_group_refs、claims。 PROMPT; } - private function buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs) + private function buildUserPrompt($sectionText, $localContext, $referText, $abstractText, $citeGroupRefs, $refCount = 0, array $referTypeMap = []) { $parts = ["【正文节 t_article_main】\n" . $sectionText]; if (trim((string)$citeGroupRefs) !== '') { @@ -250,15 +429,221 @@ PROMPT; if ($localContext !== '') { $parts[] = "【本引用位置附近上下文(优先据此拆解 claim)】\n" . $localContext; } + $typeBlock = $this->formatReferTypeBlock($referTypeMap); + if ($typeBlock !== '') { + $parts[] = $typeBlock; + } $parts[] = "【参考文献书目(按编号)】\n" . $referText; if ($abstractText !== '') { $parts[] = "【文献摘要/清洗后内容(Europe PMC·PubMed·Crossref·PDF)】\n" . $abstractText; } - $parts[] = '请先拆解最小主张单元(主语层级、证据来源、点名通路/结局逐项核对),判断每篇文献类型与**本文自身证据**,再**逐篇独立**给出单条 relevance_score(讨论转引、提取物/计算预测不得抬高;弱相关文献不得因联合而高分),最后给出 combined_*。reason / combined_reason 必须中英双语(【中文】/【English】),并分别填写 reason_en / combined_reason_en。仅输出 results 数组 JSON。'; + $tail = '请严格按六步标准校对流程执行:①提取Claim→②提取文献证据→③Claim Mapping(✔/部分/✘)→④计算覆盖率→⑤判断支持类型→⑥输出分值与理由。先在 JSON 顶层 claims 写出各 Claim 具体内容(多事实背景句须拆出比较型/成因型/进展型/数据型独立 Claim),再对每篇文献给出单条 relevance_score(弱相关文献不得因联合抬高),最后写顶层 combined_relevance_score 与 combined_reason。results 每条只写 reference_no、is_relevant、relevance_score、reason、author_comment。author_comment 规则:score<=0.65 时输出给作者看的中文批注(礼貌、可执行,≤90字);score>0.65 时固定空字符串;author_comment 禁止出现 A/B/C/D、✔/✘、Claim覆盖、具体分值。禁止 reason_en、combined_reason_en、relevance_level、relevance_role、combined_is_relevant 等多余字段。'; + if ($this->hasMixedReferTypes($referTypeMap)) { + $tail .= ' 注意:本组为图书与期刊混排引用,务必按上方【各编号文献类型标注】分轨判断——图书/教材走「书目推断」轨(据书名、副标题、作者、ISBN、出版社判断主题,缺摘要不得默认 0.25);期刊/原始研究走「摘要核对」轨(据摘要/清洗内容逐项核对 claim)。同组内两类分别按各自标准独立打分,切勿用同一把尺子。'; + } elseif ($this->allReferType($referTypeMap, 'book')) { + $tail .= ' 注意:本组全部为图书/教材(无 DOI、无外部摘要)。请走「书目推断」轨:据书名、副标题、作者专业领域、ISBN、出版社、版次判断文献类型与主题,缺摘要不得默认 0.25。'; + } + if ($refCount >= 5) { + $tail .= sprintf( + ' 本组共 %d 篇联合引用:**最硬要求:必须输出全部 %d 条 results,缺任何一条视为无效**。为防输出截断:每条 reason ≤120 字、顶层 combined_reason ≤180 字,但仍须点名各 Claim 覆盖(哪几条✔/部分/✘),不可只写"部分相关";先保证条数完整。', + $refCount, + $refCount + ); + } elseif ($refCount >= 4) { + $tail .= sprintf( + ' 本组共 %d 篇联合引用:必须输出全部 %d 条 results;每条 reason ≤180 字、顶层 combined_reason ≤260 字,逐条点名各 Claim 覆盖。', + $refCount, + $refCount + ); + } elseif ($refCount > 1) { + $tail .= sprintf( + ' 本组共 %d 篇联合引用:必须输出全部 %d 条 results;每条 reason ≤220 字、顶层 combined_reason ≤300 字,逐条点名各 Claim 覆盖与升降分依据。', + $refCount, + $refCount + ); + } else { + $tail .= ' 单条引用:reason ≤220 字,点名各 Claim 覆盖与分值依据;顶层 combined_* 与单条一致。'; + } + $parts[] = $tail; return implode("\n\n", $parts); } + /** + * 渲染各编号文献类型标注块,让 LLM 明确知道哪条是图书、哪条是期刊。 + */ + private function formatReferTypeBlock(array $referTypeMap) + { + if (empty($referTypeMap)) { + return ''; + } + ksort($referTypeMap, SORT_NUMERIC); + $labels = [ + 'book' => '图书/教材(书目推断轨:据书名/副标题/作者/ISBN/出版社判断,缺摘要不得默认 0.25)', + 'journal' => '期刊/原始研究(摘要核对轨:据摘要/清洗内容逐项核对 claim)', + 'other' => '其他/未知(尽力据书目信息判断)', + ]; + $lines = []; + foreach ($referTypeMap as $refNo => $info) { + $type = is_array($info) ? (string)($info['type'] ?? 'other') : (string)$info; + $label = isset($labels[$type]) ? $labels[$type] : $labels['other']; + $lines[] = '文献 ' . intval($refNo) . ':' . $label; + } + if (empty($lines)) { + return ''; + } + + return "【各编号文献类型标注(权威类型,按此分轨校对)】\n" . implode("\n", $lines); + } + + private function referTypeList(array $referTypeMap) + { + $types = []; + foreach ($referTypeMap as $info) { + $type = is_array($info) ? (string)($info['type'] ?? 'other') : (string)$info; + $types[$type] = true; + } + + return array_keys($types); + } + + private function hasMixedReferTypes(array $referTypeMap) + { + $types = $this->referTypeList($referTypeMap); + + return in_array('book', $types, true) + && (in_array('journal', $types, true) || in_array('other', $types, true)); + } + + private function allReferType(array $referTypeMap, $target) + { + if (empty($referTypeMap)) { + return false; + } + $types = $this->referTypeList($referTypeMap); + + return count($types) === 1 && $types[0] === $target; + } + + private function countCiteGroupRefs($citeGroupRefs) + { + $citeGroupRefs = trim((string)$citeGroupRefs); + if ($citeGroupRefs === '') { + return 0; + } + $parts = preg_split('/\s*,\s*/', $citeGroupRefs, -1, PREG_SPLIT_NO_EMPTY); + + return count($parts); + } + + private function resolveMaxTokens($refCount) + { + $refCount = max(1, intval($refCount)); + // reason/combined_reason 加详后,单篇输出更长,相应上调 token 预算防截断 + $dynamic = min(16384, max(6144, $refCount * 1600)); + if ($refCount >= 6) { + $dynamic = max($dynamic, 12288); + } + if ($this->maxTokens > 0) { + return max($this->maxTokens, $dynamic); + } + + return $dynamic; + } + + /** + * 响应截断导致缺条时,用同组已有 combined_* 与中位单条分补全(最多补 2 条)。 + */ + private function fillMissingGroupResults(array $out, $citeGroupRefs, $refCount, $combinedScore = 0, $combinedReason = '') + { + $expected = $this->parseCiteGroupRefNumbers($citeGroupRefs); + if (empty($expected)) { + return $out; + } + + $have = []; + foreach ($out as $row) { + $refNo = intval(isset($row['reference_no']) ? $row['reference_no'] : 0); + if ($refNo > 0) { + $have[$refNo] = true; + } + } + + $missing = []; + foreach ($expected as $refNo) { + if (empty($have[$refNo])) { + $missing[] = $refNo; + } + } + if (empty($missing) || count($missing) > 2 || empty($out)) { + return $out; + } + + $combinedScore = floatval($combinedScore); + $combinedReason = trim((string)$combinedReason); + $medianScore = $this->medianRelevanceScore($out); + + foreach ($missing as $refNo) { + $fillReason = '模型输出被截断未返回该文献单条结论,已按同组中位分暂填。'; + $out[] = [ + 'reference_no' => intval($refNo), + 'is_relevant' => $medianScore >= 0.65 - 0.001 ? 1 : 0, + 'relevance_score' => $medianScore, + 'reason' => $fillReason, + 'author_comment' => $this->normalizeAuthorComment('', $medianScore, $fillReason), + ]; + } + + if ($combinedScore <= 0) { + $combinedScore = $medianScore; + } + if ($combinedReason === '') { + $combinedReason = '模型输出被截断,联合结论沿用同组已返回结果。'; + } + + return $out; + } + + private function parseCiteGroupRefNumbers($citeGroupRefs) + { + $citeGroupRefs = trim((string)$citeGroupRefs); + if ($citeGroupRefs === '') { + return []; + } + $parts = preg_split('/\s*,\s*/', $citeGroupRefs, -1, PREG_SPLIT_NO_EMPTY); + $nums = []; + foreach ($parts as $part) { + $refNo = intval($part); + if ($refNo > 0) { + $nums[] = $refNo; + } + } + + return array_values(array_unique($nums)); + } + + private function medianRelevanceScore(array $rows) + { + $bands = $this->getScoreBands(); + $scores = []; + foreach ($rows as $row) { + if (!isset($row['relevance_score'])) { + continue; + } + $scores[] = $this->snapScore(floatval($row['relevance_score']), $bands); + } + if (empty($scores)) { + return 0.45; + } + sort($scores, SORT_NUMERIC); + $mid = intdiv(count($scores), 2); + if (count($scores) % 2 === 1) { + return $scores[$mid]; + } + + return $this->snapScore(($scores[$mid - 1] + $scores[$mid]) / 2, $bands); + } + private function normalizeResults(array $parsed, $defaultCiteGroupRefs, $localContext = '', $referText = '', $abstractText = '') { $rows = []; @@ -269,16 +654,17 @@ PROMPT; } $bands = $this->getScoreBands(); - $localContext = trim((string)$localContext); - $referText = trim((string)$referText); - $abstractText = trim((string)$abstractText); + $citeGroupRefs = trim((string)(isset($parsed['cite_group_refs']) ? $parsed['cite_group_refs'] : $defaultCiteGroupRefs)); + if ($citeGroupRefs === '' && $defaultCiteGroupRefs !== '') { + $citeGroupRefs = trim((string)$defaultCiteGroupRefs); + } $out = []; foreach ($rows as $item) { if (!is_array($item)) { continue; } - $refNo = intval(isset($item['reference_no']) ? $item['reference_no'] : 0); + $refNo = $this->resolveReferenceNo($item); if ($refNo <= 0) { continue; } @@ -289,76 +675,219 @@ PROMPT; $isRelevant = $this->boolVal($item['is_relevant']); } - $level = $this->levelFromScore($score, isset($item['relevance_level']) ? $item['relevance_level'] : ''); - $role = $this->normalizeRelevanceRole(isset($item['relevance_role']) ? $item['relevance_role'] : ''); - list($reason, $reasonEn) = $this->normalizeBilingualReason( + $reason = $this->normalizeChineseReason( isset($item['reason']) ? $item['reason'] : '', isset($item['reason_en']) ? $item['reason_en'] : '' ); - list($score, $level, $isRelevant, $role) = $this->enforceSingleReferenceConsistency( + $level = $this->levelFromScore($score, isset($item['relevance_level']) ? $item['relevance_level'] : ''); + $role = $this->normalizeRelevanceRole(isset($item['relevance_role']) ? $item['relevance_role'] : ''); + list($score, $level, $isRelevant, $role) = $this->enforceSingleReferenceConsistency($score, $level, $isRelevant, $role, $bands); + if ($reason === '') { + $reason = $this->fallbackReasonFromScore($score, $level); + } else { + $refLit = $this->extractRefLiteratureFromCombined($abstractText, $refNo); + $adjusted = $this->reconcileScoreAgainstCoverage($score, $reason, $refLit); + $capped = $this->reconcileScoreCeiling($adjusted, $reason); + if ($capped > $score + 0.001 || $capped < $score - 0.001) { + if ($capped > $score + 0.001) { + \think\Log::warning(sprintf( + 'ReferenceRelevanceLlm: raised ref#%d score %.2f->%.2f (coverage reconcile)', + $refNo, + $score, + $capped + )); + } + $score = $capped; + $isRelevant = $score >= 0.65 - 0.001; + } + $reason = $this->reconcileReasonScore($reason, $score); + } + $authorComment = $this->normalizeAuthorComment( + isset($item['author_comment']) ? $item['author_comment'] : '', $score, - $level, - $isRelevant, - $role, - $bands + $reason ); - $combinedScore = $this->snapScore( - floatval(isset($item['combined_relevance_score']) ? $item['combined_relevance_score'] : $score), - $bands - ); - $combinedRelevant = $combinedScore >= 0.65 - 0.001; - if (array_key_exists('combined_is_relevant', $item)) { - $combinedRelevant = $this->boolVal($item['combined_is_relevant']); - } - - $combinedLevel = $this->levelFromScore( - $combinedScore, - isset($item['combined_relevance_level']) ? $item['combined_relevance_level'] : '' - ); - list($combinedScore, $combinedLevel, $combinedRelevant) = $this->enforceCombinedConsistency( - $combinedScore, - $combinedLevel, - $combinedRelevant, - $bands - ); - - $citeGroupRefs = trim((string)(isset($item['cite_group_refs']) ? $item['cite_group_refs'] : $defaultCiteGroupRefs)); - if ($citeGroupRefs === '' && $defaultCiteGroupRefs !== '') { - $citeGroupRefs = trim((string)$defaultCiteGroupRefs); - } - - list($combinedReason, $combinedReasonEn) = $this->normalizeBilingualReason( - isset($item['combined_reason']) ? $item['combined_reason'] : '', - isset($item['combined_reason_en']) ? $item['combined_reason_en'] : '' - ); - if ($combinedReason === '' && $combinedReasonEn === '') { - list($combinedReason, $combinedReasonEn) = [$reason, $reasonEn]; - } - $out[] = [ - 'reference_no' => $refNo, - 'cite_group_refs' => $citeGroupRefs, - 'is_relevant' => $isRelevant ? 1 : 0, - 'relevance_score' => $score, - 'relevance_level' => $level, - 'relevance_role' => $role, - 'reason' => $reason, - 'reason_en' => $reasonEn, - 'combined_is_relevant' => $combinedRelevant ? 1 : 0, - 'combined_relevance_score' => $combinedScore, - 'combined_relevance_level' => $combinedLevel, - 'combined_reason' => $combinedReason, - 'combined_reason_en' => $combinedReasonEn, + 'reference_no' => $refNo, + 'is_relevant' => $isRelevant ? 1 : 0, + 'relevance_score' => $score, + 'reason' => $reason, + 'author_comment' => $authorComment, ]; } - $out = $this->syncCombinedFieldsAcrossGroup($out); + $groupCombined = $this->resolveGroupCombinedFields($parsed, $rows, $out, $citeGroupRefs, $bands); + $claims = $this->normalizeClaims(isset($parsed['claims']) ? $parsed['claims'] : []); + + return [ + 'results' => $out, + 'claims' => $claims, + 'combined_relevance_score' => floatval($groupCombined['combined_relevance_score']), + 'combined_reason' => (string)$groupCombined['combined_reason'], + ]; + } + + /** + * 归一化顶层 claims:键 A/B/C…,值为各 Claim 中文具体内容。 + */ + private function normalizeClaims($raw) + { + if (!is_array($raw)) { + return []; + } + + $out = []; + foreach ($raw as $key => $val) { + if (is_array($val)) { + $k = strtoupper(trim((string)($val['id'] ?? $val['key'] ?? $key))); + $text = trim((string)($val['content'] ?? $val['text'] ?? '')); + } else { + $k = strtoupper(trim((string)$key)); + $text = trim((string)$val); + } + if (!preg_match('/^[A-Z]$/', $k) || $text === '') { + continue; + } + $out[$k] = mb_substr($text, 0, 500); + } + ksort($out, SORT_STRING); return $out; } + /** + * 从 JSON 顶层读取联合结论;兼容旧版 results 内嵌 combined_*。 + */ + private function resolveGroupCombinedFields(array $parsed, array $rawRows, array $outRows, $citeGroupRefs, array $bands) + { + $combinedScore = 0.0; + if (array_key_exists('combined_relevance_score', $parsed)) { + $combinedScore = floatval($parsed['combined_relevance_score']); + } + + $combinedReason = $this->normalizeChineseReason( + isset($parsed['combined_reason']) ? $parsed['combined_reason'] : '', + isset($parsed['combined_reason_en']) ? $parsed['combined_reason_en'] : '' + ); + + if ($combinedScore <= 0 || $combinedReason === '') { + foreach ($rawRows as $item) { + if (!is_array($item)) { + continue; + } + if ($combinedScore <= 0 && array_key_exists('combined_relevance_score', $item)) { + $combinedScore = floatval($item['combined_relevance_score']); + } + if ($combinedReason === '') { + $combinedReason = $this->normalizeChineseReason( + isset($item['combined_reason']) ? $item['combined_reason'] : '', + isset($item['combined_reason_en']) ? $item['combined_reason_en'] : '' + ); + } + if ($combinedScore > 0 && $combinedReason !== '') { + break; + } + } + } + + if (count($outRows) === 1) { + if ($combinedScore <= 0) { + $combinedScore = floatval($outRows[0]['relevance_score']); + } + if ($combinedReason === '') { + $combinedReason = (string)$outRows[0]['reason']; + } + } elseif ($combinedScore <= 0 && !empty($outRows)) { + $scores = []; + foreach ($outRows as $row) { + $scores[] = floatval($row['relevance_score']); + } + rsort($scores, SORT_NUMERIC); + $combinedScore = floatval($scores[0]); + } + + if ($combinedReason === '' && $combinedScore > 0) { + $combinedReason = $this->fallbackReasonFromScore($combinedScore, $this->levelFromScore($combinedScore)); + } + + $combinedScore = $this->enforceCombinedAgainstSingles($outRows, $combinedScore, $bands); + if ($combinedScore <= 0.45 && $combinedReason !== '') { + $maxSingle = $this->maxSingleRelevanceScore($outRows); + if ($maxSingle <= 0.45) { + $combinedReason = $this->fallbackReasonFromScore($combinedScore, $this->levelFromScore($combinedScore)); + } + } + + list($combinedScore,) = $this->enforceCombinedConsistency($combinedScore, '', $bands); + $combinedReason = $this->reconcileReasonScore($this->cleanReason($combinedReason), $combinedScore); + + return [ + 'combined_relevance_score' => $combinedScore, + 'combined_reason' => $combinedReason, + ]; + } + + /** + * 联合分不得远高于单条分;全部单条弱相关时联合不得高分。 + */ + private function enforceCombinedAgainstSingles(array $outRows, $combinedScore, array $bands) + { + $combinedScore = floatval($combinedScore); + if (empty($outRows)) { + return $this->snapScore($combinedScore, $bands); + } + + $scores = []; + foreach ($outRows as $row) { + $scores[] = floatval(isset($row['relevance_score']) ? $row['relevance_score'] : 0); + } + if (empty($scores)) { + return $this->snapScore($combinedScore, $bands); + } + + $maxSingle = max($scores); + if ($maxSingle <= 0.25 + 0.001) { + return $this->snapScore(min($combinedScore, 0.25), $bands); + } + if ($maxSingle <= 0.45 + 0.001) { + return $this->snapScore(min($combinedScore, 0.45), $bands); + } + + $ceiling = $maxSingle; + if ($maxSingle >= 0.92) { + $ceiling = 0.98; + } elseif ($maxSingle >= 0.85) { + $ceiling = 0.92; + } elseif ($maxSingle >= 0.78) { + $ceiling = 0.92; + } elseif ($maxSingle >= 0.65) { + $ceiling = 0.85; + } elseif ($maxSingle >= 0.45) { + $ceiling = 0.78; + } + + if ($combinedScore > $ceiling) { + $combinedScore = $ceiling; + } + + return $this->snapScore($combinedScore, $bands); + } + + private function maxSingleRelevanceScore(array $outRows) + { + $max = 0.0; + foreach ($outRows as $row) { + $score = floatval(isset($row['relevance_score']) ? $row['relevance_score'] : 0); + if ($score > $max) { + $max = $score; + } + } + + return $max; + } + private function enforceSingleReferenceConsistency($score, $level, $isRelevant, $role, array $bands) { $score = floatval($score); @@ -421,50 +950,12 @@ PROMPT; return [$score, $level, $isRelevant, $role]; } - private function enforceCombinedConsistency($combinedScore, $combinedLevel, $combinedRelevant, array $bands) + private function enforceCombinedConsistency($combinedScore, $combinedLevel, array $bands) { $combinedScore = $this->snapScore(floatval($combinedScore), $bands); $combinedLevel = $this->levelFromScore($combinedScore, $combinedLevel); - $combinedRelevant = $combinedScore >= 0.65 - 0.001; - return [$combinedScore, $combinedLevel, $combinedRelevant]; - } - - private function syncCombinedFieldsAcrossGroup(array $out) - { - $groups = []; - foreach ($out as $idx => $row) { - $key = (string)$row['cite_group_refs']; - if ($key === '') { - $key = 'ref:' . $row['reference_no']; - } - $groups[$key][] = $idx; - } - - foreach ($groups as $indices) { - if (count($indices) <= 1) { - continue; - } - $bestIdx = $indices[0]; - $bestScore = floatval($out[$bestIdx]['combined_relevance_score']); - foreach ($indices as $idx) { - $s = floatval($out[$idx]['combined_relevance_score']); - if ($s >= $bestScore) { - $bestScore = $s; - $bestIdx = $idx; - } - } - $src = $out[$bestIdx]; - foreach ($indices as $idx) { - $out[$idx]['combined_is_relevant'] = intval($src['combined_is_relevant']); - $out[$idx]['combined_relevance_score'] = floatval($src['combined_relevance_score']); - $out[$idx]['combined_relevance_level'] = (string)$src['combined_relevance_level']; - $out[$idx]['combined_reason'] = (string)$src['combined_reason']; - $out[$idx]['combined_reason_en'] = (string)$src['combined_reason_en']; - } - } - - return $out; + return [$combinedScore, $combinedLevel]; } private function getScoreBands() @@ -539,7 +1030,7 @@ PROMPT; } } - return 'no_meaningful_relevance'; + return ''; } private function cleanReason($reason) @@ -549,38 +1040,371 @@ PROMPT; return mb_substr($reason, 0, 2000); } + private function resolveReferenceNo(array $item) + { + foreach (['reference_no', 'ref_no', 'reference_number'] as $key) { + if (!isset($item[$key])) { + continue; + } + $refNo = intval($item[$key]); + if ($refNo > 0) { + return $refNo; + } + } + + return 0; + } + + private function fallbackReasonFromScore($score, $level = '') + { + $level = trim((string)$level); + if ($level === 'highly_related') { + return '文献与引用处主题高度相关。'; + } + if ($level === 'partially_related') { + return '文献与引用处主题部分相关。'; + } + if ($level === 'weakly_related') { + return '文献与引用处主题关联较弱。'; + } + if ($level === 'unrelated') { + return '文献与引用处主题基本不相关。'; + } + $score = floatval($score); + if ($score >= 0.85) { + return '文献与引用处主题高度相关。'; + } + if ($score >= 0.65) { + return '文献与引用处主题部分相关。'; + } + if ($score >= 0.45) { + return '文献与引用处主题关联较弱。'; + } + + return '文献与引用处主题基本不相关。'; + } + /** - * @return array{0:string,1:string} [bilingual reason, english only] + * 从 reason 的「Claim覆盖」段落按顺序解析各 Claim 覆盖情况, + * 正确处理合并写法(如「D/F/G✘」= 3 个 ✘、「A✔ B✔」= 2 个 ✔)。 + * + * @return array{full:int,partial:int,fail:int,total:int} */ - private function normalizeBilingualReason($reason, $reasonEn) + private function countCoverageSignals($reason) + { + $reason = (string)$reason; + + // 尽量截取「Claim覆盖:...」到句号之间的覆盖清单,避免正文其他大写字母(如 PI3K/AKT)干扰 + $covText = $reason; + if (preg_match('/Claim\s*覆盖[::]\s*(.+?)(?:。|$)/us', $reason, $m)) { + $covText = $m[1]; + } + + $full = $partial = $fail = 0; + if (preg_match_all('/([A-Z])(?:\s*(✔|✘|×|部分))/u', $covText, $tokens, PREG_SET_ORDER)) { + foreach ($tokens as $tok) { + $mark = $tok[2]; + if ($mark === '✔') { + $full++; + } elseif ($mark === '部分') { + $partial++; + } else { + $fail++; + } + } + } + + return [ + 'full' => $full, + 'partial' => $partial, + 'fail' => $fail, + 'total' => $full + $partial + $fail, + ]; + } + + /** + * 从联合文献块中提取指定编号的摘要/清洗内容。 + */ + private function extractRefLiteratureFromCombined($abstractText, $refNo) + { + $abstractText = (string)$abstractText; + $refNo = intval($refNo); + if ($abstractText === '' || $refNo <= 0) { + return ''; + } + if (preg_match('/【参考文献\s+' . $refNo . '】\s*\n(.*?)(?=\n\n【参考文献\s+\d+】|\z)/us', $abstractText, $m)) { + return trim($m[1]); + } + + return ''; + } + + /** + * 判断文献材料是否呈现「学科理论发展/议题/未来方向」型综述特征(通用关键词,不限定具体文献)。 + */ + private function literatureSupportsDisciplineTheoryReview($text) + { + $text = strtolower((string)$text); + if ($text === '') { + return false; + } + $keys = [ + '理论', '发展', '贡献', '挑战', '未来', '知识', '实践', '框架', '结构', '学科', + 'theory', 'development', 'future', 'knowledge', 'practice', 'discipline', 'framework', + ]; + $hits = 0; + foreach ($keys as $key) { + if (strpos($text, $key) !== false) { + $hits++; + } + } + + return $hits >= 6; + } + + /** + * 分值-覆盖自洽兜底(通用,不针对具体文献/学科): + * 当 relevance_score 偏低(≤0.45)但 reason 写出的 Claim 覆盖明显更高、 + * 且无「主语不一致/证据层级不足」等合理低分信号时,按覆盖比例抬升到对应档位。 + */ + private function reconcileScoreAgainstCoverage($score, $reason, $literatureContext = '') + { + $score = floatval($score); + // 仅纠正明显偏低的分值,不动中高分 + if ($score > 0.45 + 0.001) { + return $score; + } + $reason = (string)$reason; + if ($reason === '') { + return $score; + } + + // 合理低分信号:主语/层级不对、证据层级不足、类型不适配、几乎无覆盖等 → 低分成立,不抬升 + if (preg_match('/主语不一致|主语层级|层级不对|层级不一致|层级偏|类型不符合|类型不适配|不适合支撑|几乎全[✘×]|均未/ui', $reason)) { + return $score; + } + + $bands = $this->getScoreBands(); + $litBlob = trim($literatureContext . "\n" . $reason); + $isPhilosophyMeta = preg_match('/哲学|元范式|metaparadigm|本体论|ontology|学科本体|disciplinary\s+inquir/ui', $litBlob); + + $cov = $this->countCoverageSignals($reason); + $full = $cov['full']; + $partial = $cov['partial']; + $fail = $cov['fail']; + $total = $cov['total']; + + // 「基本不相关」仅当无任何✔/部分时可阻止抬分 + if (preg_match('/基本不相关/ui', $reason) && $full === 0 && $partial === 0) { + return $score; + } + + // 流行病学数据 claim 用指南/治疗文献支撑:同领域错类型 → 至少 0.45 + if (preg_match('/主语一致/u', $reason) + && preg_match('/患病率|发病率|流行病学|标准化患病率|prevalence|incidence/ui', $reason) + && preg_match('/指南|治疗进展|诊疗指南|guideline|非流行病学/ui', $reason) + && $score <= 0.25 + 0.001) { + return $this->snapScore(0.45, $bands); + } + + // 主语一致且有任意覆盖:禁止 0.25 + if (preg_match('/主语一致/u', $reason) + && ($full >= 1 || $partial >= 1) + && $score <= 0.25 + 0.001) { + return $this->snapScore(0.45, $bands); + } + + // 哲学/元范式/本体论:主语一致且至少一项 Claim 有覆盖(✔或部分)→ 不低于 0.78(规则16) + if ($isPhilosophyMeta + && preg_match('/主语一致/u', $reason) + && ($full >= 1 || $partial >= 1) + && $total >= 1) { + return $this->snapScore(0.78, $bands); + } + + // 理论发展/议题综述(非哲学元层):材料丰富且 A✔、主语一致 → 抬到 0.85 + if (!$isPhilosophyMeta + && preg_match('/A✔/u', $reason) + && preg_match('/主语一致/u', $reason) + && preg_match('/综述|回顾|述评|发展|挑战|未来|方向|review|narrative/ui', $litBlob) + && $this->literatureSupportsDisciplineTheoryReview($litBlob)) { + return $this->snapScore(0.85, $bands); + } + + // 单 Claim 强匹配:A✔ + 主语一致 + 类型完全匹配/权威数据支撑 → 不得 0.25(规则14) + if (preg_match('/主语一致/u', $reason) + && $full >= 1 + && $fail === 0 + && preg_match('/类型完全匹配|完全匹配|高度匹配|权威|明确提供|直接支撑|支撑.*claim|患病率|发病率|流行病学|epidemiol/ui', $reason)) { + $epiMatch = preg_match('/流行病学|患病率|发病率|负担|prevalence|incidence|mortality|epidemiol/ui', $reason . $literatureContext); + $floor = ($epiMatch || preg_match('/明确提供|直接支撑|权威/ui', $reason)) ? 0.92 : 0.85; + return $this->snapScore(max($score, $floor), $bands); + } + + // 覆盖信息不足以判断(无任何标注)→ 保持原分 + if ($total < 1 || ($full === 0 && $partial === 0)) { + return $score; + } + + // 覆盖比例:完整覆盖计 1、部分覆盖计 0.5 + $ratio = ($full + 0.5 * $partial) / $total; + $systemCover = preg_match('/系统(?:阐述|覆盖)|全部\s*Claim|全部\s*覆盖|高度匹配/u', $reason); + + $floor = 0.0; + if ($ratio >= 0.9 && $fail === 0) { + $floor = $systemCover ? 0.98 : 0.92; + } elseif ($ratio >= 0.72) { + $floor = 0.92; + } elseif ($ratio >= 0.58) { + $floor = 0.85; + } elseif ($ratio >= 0.45) { + $floor = 0.78; + } elseif ($ratio >= 0.28) { + $floor = 0.65; + } elseif ($ratio >= 0.15 || ($partial >= 1 && preg_match('/主语一致/u', $reason))) { + $floor = 0.45; + } else { + return $score; + } + + return $this->snapScore(max($score, $floor), $bands); + } + + /** + * 对偏高分值封顶(多事实背景句、多项✘、比较型不支持等)。 + */ + private function reconcileScoreCeiling($score, $reason) + { + $score = floatval($score); + $reason = (string)$reason; + if ($reason === '') { + return $score; + } + + $bands = $this->getScoreBands(); + $cov = $this->countCoverageSignals($reason); + $fail = $cov['fail']; + $total = $cov['total']; + + if ($total >= 3 && $fail >= 2 && $score > 0.85) { + $score = 0.78; + } + if ($total >= 3 && $fail >= 2 && preg_match('/原始研究/ui', $reason) && $score > 0.78) { + $score = 0.78; + } + if ($fail >= 1 && preg_match('/比较|低于其他|高于其他|与其他|慢病|chronic disease/ui', $reason) + && preg_match('/[A-Z][^。]*✘/u', $reason) + && $score > 0.65) { + $score = 0.65; + } + if ($total >= 4 && $fail >= 2 && preg_match('/原始研究/ui', $reason) && $score > 0.65) { + $score = 0.65; + } + + return $this->snapScore($score, $bands); + } + + /** + * 复核 reason 结论处的「故 X.XX」分值,使其与最终 relevance_score 一致, + * 避免出现「relevance_score=0.25 却 reason 故 0.92」的自相矛盾。 + */ + private function reconcileReasonScore($reason, $score) { $reason = trim((string)$reason); - $reasonEn = $this->cleanReason($reasonEn); - - if ($reasonEn === '' && preg_match('/【English】\s*(.+)$/us', $reason, $m)) { - $reasonEn = $this->cleanReason($m[1]); + if ($reason === '') { + return $reason; } - $zh = ''; - if (preg_match('/【中文】\s*(.*?)(?:\n【English】|$)/us', $reason, $m)) { - $zh = trim($m[1]); - } elseif ($reason !== '' && strpos($reason, '【English】') === false) { - $zh = trim($reason); + $scoreStr = number_format((float)$score, 2, '.', ''); + // 覆盖「故 0.92」「故联合 0.92」「故联合分 0.92」等结论写法 + $pattern = '/(故\s*(?:联合分?)?\s*)([01](?:\.\d+)?)/u'; + if (!preg_match($pattern, $reason)) { + return $reason; } - if ($zh !== '' && $reasonEn !== '' && strpos($reason, '【English】') === false) { - $reason = "【中文】{$zh}\n【English】{$reasonEn}"; - } elseif ($zh !== '' && $reasonEn !== '' && strpos($reason, '【中文】') === false) { - $reason = "【中文】{$zh}\n【English】{$reasonEn}"; - } else { - $reason = $this->cleanReason($reason); + return preg_replace_callback($pattern, function ($m) use ($scoreStr) { + return $m[1] . $scoreStr; + }, $reason); + } + + /** + * 归一化为中文结论;兼容旧版双语格式或 reason_en 英文字段。 + */ + private function normalizeChineseReason($reason, $fallbackEn = '') + { + $reason = trim((string)$reason); + if ($reason !== '' && preg_match('/【中文】\s*(.+?)(?:\s*【English】|$)/us', $reason, $m)) { + $cn = $this->cleanReason($m[1]); + if ($cn !== '') { + return $cn; + } } - if ($reasonEn === '' && $zh !== '') { - $reasonEn = ''; + if ($reason !== '') { + if (preg_match('/【English】\s*(.+)$/us', $reason, $m)) { + return $this->cleanReason($m[1]); + } + + return $this->cleanReason($reason); } - return [$reason, $reasonEn]; + $fallbackEn = $this->cleanReason($fallbackEn); + if ($fallbackEn !== '') { + return $fallbackEn; + } + + return ''; + } + + private function normalizeAuthorComment($authorComment, $score, $reason) + { + $score = floatval($score); + if ($score > 0.65 + 0.001) { + return ''; + } + + $authorComment = trim((string)$authorComment); + if ($authorComment !== '') { + $authorComment = $this->sanitizeAuthorCommentText($authorComment); + if ($authorComment !== '') { + return mb_substr($authorComment, 0, 120); + } + } + + $reason = trim((string)$reason); + if ($reason === '') { + return '该条参考文献与正文匹配度有限,建议补充更直接支持该论点的文献。'; + } + // 去掉显式分数结论,保留给作者可读的改进建议 + $reason = preg_replace('/Claim覆盖[::].*/u', '', $reason); + $reason = preg_replace('/故\s*(?:联合分?)?\s*[01](?:\.\d+)?[。.]?/u', '', $reason); + $reason = trim((string)$reason); + if ($reason === '') { + return '该条参考文献与正文匹配度有限,建议补充更直接支持该论点的文献。'; + } + $reason = $this->sanitizeAuthorCommentText($reason); + if ($reason === '') { + return '该条参考文献与正文匹配度有限,建议补充更直接支持该论点的文献。'; + } + + return mb_substr('建议:' . $reason, 0, 120); + } + + private function sanitizeAuthorCommentText($text) + { + $text = trim((string)$text); + if ($text === '') { + return ''; + } + $text = preg_replace('/Claim覆盖[::][^。;;\n]*/u', '', $text); + $text = preg_replace('/\b[A-E]\s*(?:[✔✘]|部分)\b/u', '', $text); + $text = preg_replace('/[✔✘]/u', '', $text); + $text = preg_replace('/\b0?\.\d{1,2}\b/u', '', $text); + $text = preg_replace('/\b\d{1,3}%\b/u', '', $text); + $text = preg_replace('/\s{2,}/u', ' ', $text); + $text = trim((string)$text, " \t\n\r\0\x0B;;,,"); + + return $text; } private function boolVal($v) @@ -612,14 +1436,27 @@ PROMPT; } curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); $raw = curl_exec($ch); + $info = curl_getinfo($ch); + $timingSummary = $this->buildCurlTimingSummary($info); if ($raw === false) { $this->lastPostError = 'LLM curl error: ' . curl_error($ch); - \think\Log::warning('ReferenceRelevanceLlm: ' . $this->lastPostError); + $errno = intval(curl_errno($ch)); + \think\Log::warning(sprintf( + 'ReferenceRelevanceLlm: %s; errno=%d; timing={%s}', + $this->lastPostError, + $errno, + $timingSummary + )); curl_close($ch); return null; } - $httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE)); + $httpCode = intval(isset($info['http_code']) ? $info['http_code'] : 0); curl_close($ch); + \think\Log::info(sprintf( + 'ReferenceRelevanceLlm request completed: http=%d; timing={%s}', + $httpCode, + $timingSummary + )); if ($httpCode < 200 || $httpCode >= 300) { $snippet = mb_substr(trim((string)$raw), 0, 200); $this->lastPostError = 'LLM HTTP ' . $httpCode . ($snippet !== '' ? ': ' . $snippet : ''); @@ -646,6 +1483,28 @@ PROMPT; return null; } + private function buildCurlTimingSummary(array $info) + { + $nameLookupMs = intval(round(floatval(isset($info['namelookup_time']) ? $info['namelookup_time'] : 0) * 1000)); + $connectMs = intval(round(floatval(isset($info['connect_time']) ? $info['connect_time'] : 0) * 1000)); + $appConnectMs = intval(round(floatval(isset($info['appconnect_time']) ? $info['appconnect_time'] : 0) * 1000)); + $startTransferMs = intval(round(floatval(isset($info['starttransfer_time']) ? $info['starttransfer_time'] : 0) * 1000)); + $totalMs = intval(round(floatval(isset($info['total_time']) ? $info['total_time'] : 0) * 1000)); + $sizeDownload = intval(isset($info['size_download']) ? $info['size_download'] : 0); + $httpCode = intval(isset($info['http_code']) ? $info['http_code'] : 0); + + return sprintf( + 'dns_ms=%d, connect_ms=%d, tls_ms=%d, ttfb_ms=%d, total_ms=%d, http=%d, size_download=%d', + $nameLookupMs, + $connectMs, + $appConnectMs, + $startTransferMs, + $totalMs, + $httpCode, + $sizeDownload + ); + } + private function parseJson($raw) { $raw = trim((string)$raw); @@ -654,17 +1513,292 @@ PROMPT; } $raw = preg_replace('/^```[a-zA-Z]*\s*|```$/m', '', $raw); $raw = trim($raw); + $raw = $this->repairJsonNewlinesInStrings($raw); + $raw = $this->repairUnescapedQuotesInStrings($raw); + $decoded = json_decode($raw, true); if (is_array($decoded)) { - return $decoded; + return $this->filterCompleteResults($decoded); } - if (preg_match('/\{[\s\S]*\}/', $raw, $m)) { - $decoded = json_decode($m[0], true); + + if (preg_match('/\{[\s\S]*/', $raw, $m)) { + $chunk = $this->repairTruncatedJson($m[0]); + $decoded = json_decode($chunk, true); if (is_array($decoded)) { - return $decoded; + return $this->filterCompleteResults($decoded); + } + } + + return $this->salvagePartialResults($raw); + } + + private function repairJsonNewlinesInStrings($json) + { + $out = ''; + $inString = false; + $escape = false; + $len = strlen($json); + for ($i = 0; $i < $len; $i++) { + $ch = $json[$i]; + if ($escape) { + $out .= $ch; + $escape = false; + continue; + } + if ($ch === '\\' && $inString) { + $out .= $ch; + $escape = true; + continue; + } + if ($ch === '"') { + $inString = !$inString; + $out .= $ch; + continue; + } + if ($inString && ($ch === "\n" || $ch === "\r")) { + $out .= '\\n'; + continue; + } + $out .= $ch; + } + + return $out; + } + + /** + * 修复字符串内部未转义双引号(常见于 reason 文本中的英文引号)。 + * 规则:字符串内遇到引号时,若其后最近非空白字符不是 JSON 分隔符(, ] } :), + * 则视为内容中的裸引号并转义为 \",避免整段 JSON 解析失败。 + */ + private function repairUnescapedQuotesInStrings($json) + { + $out = ''; + $inString = false; + $escape = false; + $len = strlen($json); + for ($i = 0; $i < $len; $i++) { + $ch = $json[$i]; + if ($escape) { + $out .= $ch; + $escape = false; + continue; + } + if ($ch === '\\' && $inString) { + $out .= $ch; + $escape = true; + continue; + } + if ($ch === '"') { + if (!$inString) { + $inString = true; + $out .= $ch; + continue; + } + $next = $this->nextNonSpaceChar($json, $i + 1); + if ($next === null || $next === ',' || $next === ']' || $next === '}' || $next === ':') { + $inString = false; + $out .= $ch; + } else { + $out .= '\\"'; + } + continue; + } + $out .= $ch; + } + + return $out; + } + + private function nextNonSpaceChar($text, $start) + { + $len = strlen((string)$text); + for ($i = max(0, intval($start)); $i < $len; $i++) { + $ch = $text[$i]; + if ($ch !== ' ' && $ch !== "\t" && $ch !== "\r" && $ch !== "\n") { + return $ch; } } return null; } + + private function repairTruncatedJson($json) + { + $json = rtrim($json); + if ($json === '') { + return $json; + } + $inString = false; + $escape = false; + $stack = []; + $len = strlen($json); + for ($i = 0; $i < $len; $i++) { + $ch = $json[$i]; + if ($escape) { + $escape = false; + continue; + } + if ($ch === '\\' && $inString) { + $escape = true; + continue; + } + if ($ch === '"') { + $inString = !$inString; + continue; + } + if ($inString) { + continue; + } + if ($ch === '{' || $ch === '[') { + $stack[] = $ch; + } elseif ($ch === '}' && !empty($stack) && end($stack) === '{') { + array_pop($stack); + } elseif ($ch === ']' && !empty($stack) && end($stack) === '[') { + array_pop($stack); + } + } + if ($inString) { + $json .= '"'; + } + while (!empty($stack)) { + $open = array_pop($stack); + $json .= $open === '{' ? '}' : ']'; + } + + return $json; + } + + private function salvagePartialResults($raw) + { + $pos = strpos($raw, '"results"'); + if ($pos === false) { + return null; + } + $start = strpos($raw, '[', $pos); + if ($start === false) { + return null; + } + + $objs = []; + $depth = 0; + $objStart = null; + $len = strlen($raw); + for ($i = $start + 1; $i < $len; $i++) { + $ch = $raw[$i]; + if ($ch === '{') { + if ($depth === 0) { + $objStart = $i; + } + $depth++; + } elseif ($ch === '}') { + $depth--; + if ($depth === 0 && $objStart !== null) { + $chunk = substr($raw, $objStart, $i - $objStart + 1); + $chunk = $this->repairJsonNewlinesInStrings($chunk); + $chunk = $this->repairUnescapedQuotesInStrings($chunk); + $item = json_decode($chunk, true); + if (is_array($item) && $this->isCompleteResultObject($item)) { + $objs[] = $item; + } + $objStart = null; + } + } + } + + if (empty($objs)) { + return null; + } + + return ['results' => $objs]; + } + + private function filterCompleteResults(array $parsed) + { + if (!isset($parsed['results']) || !is_array($parsed['results'])) { + return $parsed; + } + $parsed['results'] = array_values(array_filter($parsed['results'], function ($item) { + return is_array($item) && $this->isCompleteResultObject($item); + })); + + return $parsed; + } + + private function isCompleteResultObject(array $item) + { + if ($this->resolveReferenceNo($item) <= 0) { + return false; + } + if (!array_key_exists('relevance_score', $item) && !array_key_exists('is_relevant', $item)) { + return false; + } + + $rawReason = isset($item['reason']) ? trim((string)$item['reason']) : ''; + if ($rawReason === '' && empty($item['reason_en'])) { + return true; + } + + $reason = $this->normalizeChineseReason( + isset($item['reason']) ? $item['reason'] : '', + isset($item['reason_en']) ? $item['reason_en'] : '' + ); + if ($reason !== '' && mb_strlen($reason) < 4) { + return false; + } + if ($rawReason !== '' && $this->looksTruncatedString($rawReason)) { + return false; + } + + return true; + } + + private function looksTruncatedString($text) + { + $text = trim((string)$text); + if ($text === '' || mb_strlen($text) < 80) { + return false; + } + $last = mb_substr($text, -1); + + return !preg_match('/[.!?)"\'\x{3002}\x{ff01}\x{ff1f}\x{ff09}\x{3011}\x{300d}\x{2026}]/u', $last); + } + + private function isTruncatedResponse($raw) + { + $raw = rtrim(trim((string)$raw)); + if ($raw === '') { + return false; + } + if (substr($raw, -1) === '}') { + return false; + } + + return $this->looksTruncatedString($raw) || preg_match('/"[^"]*$/s', $raw); + } + + private function saveBadJsonResponse($raw, array $meta = []) + { + $dir = dirname(dirname(dirname(__DIR__))) . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR . 'log' . DIRECTORY_SEPARATOR . 'reference_relevance_llm_bad_json'; + if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { + return ''; + } + + $cite = preg_replace('/[^\d,]/', '', (string)(isset($meta['cite_group_refs']) ? $meta['cite_group_refs'] : '')); + $cite = $cite !== '' ? $cite : 'unknown'; + $name = date('Ymd_His') . '_' . $cite . '_' . substr(md5((string)$raw), 0, 8) . '.json'; + $path = $dir . DIRECTORY_SEPARATOR . $name; + + $payload = array_merge([ + 'saved_at' => date('Y-m-d H:i:s'), + 'model' => $this->model, + 'raw_length' => strlen((string)$raw), + 'raw' => (string)$raw, + ], $meta); + + if (@file_put_contents($path, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) === false) { + return ''; + } + + return 'runtime/log/reference_relevance_llm_bad_json/' . $name; + } } diff --git a/application/extra/rabbitmq.php b/application/extra/rabbitmq.php index 05aa89b4..5eb8c0ca 100644 --- a/application/extra/rabbitmq.php +++ b/application/extra/rabbitmq.php @@ -13,4 +13,11 @@ return [ 'dlq' => 'ref_check.article.dlq', 'route_key' => 'article.start', ], + + 'ai_writing_risk' => [ + 'exchange' => 'ai_writing_risk', + 'queue' => 'ai_writing_risk.task', + 'dlq' => 'ai_writing_risk.task.dlq', + 'route_key' => 'task.start', + ], ]; From a46df660280f9431338b977e0f8dde632e5322a6 Mon Sep 17 00:00:00 2001 From: wyn <1074145239@qq.com> Date: Wed, 15 Jul 2026 10:55:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?ai=E5=86=99=E4=BD=9C=E8=BE=85=E5=8A=A9?= =?UTF-8?q?=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AiTemplateSentenceRuleDetectService.php | 168 +++++++++++++ .../common/AiTemplateSentenceService.php | 176 ++++++++++++++ .../common/AiWritingRiskAnalysisService.php | 230 ++++++++++++++++++ .../common/ManuscriptTextCleanService.php | 121 +++++++++ 4 files changed, 695 insertions(+) create mode 100644 application/common/AiTemplateSentenceRuleDetectService.php create mode 100644 application/common/AiTemplateSentenceService.php create mode 100644 application/common/AiWritingRiskAnalysisService.php create mode 100644 application/common/ManuscriptTextCleanService.php diff --git a/application/common/AiTemplateSentenceRuleDetectService.php b/application/common/AiTemplateSentenceRuleDetectService.php new file mode 100644 index 00000000..2ced0b3b --- /dev/null +++ b/application/common/AiTemplateSentenceRuleDetectService.php @@ -0,0 +1,168 @@ +|null */ + private static $ruleCache; + + /** + * @param array $sectionTexts 键:abstract/introduction/... + * @return array{hits:array,rule_score:int} + */ + public function detect(array $sectionTexts): array + { + $rules = $this->loadActiveRules(); + $hits = []; + $ruleScore = 0; + + $normalized = []; + foreach ($sectionTexts as $key => $text) { + $section = strtolower(trim((string) $key)); + if ($section === '') { + continue; + } + $normalized[$section] = ManuscriptTextCleanService::clean((string) $text); + } + $allText = ManuscriptTextCleanService::clean(implode("\n\n", array_filter($normalized))); + + foreach ($rules as $rule) { + $section = strtolower(trim((string) ($rule['section'] ?? ''))); + $pattern = trim((string) ($rule['sentence_pattern'] ?? '')); + if ($pattern === '') { + continue; + } + + $targetText = ''; + if ($section === 'all') { + $targetText = $allText; + } elseif (isset($normalized[$section])) { + $targetText = $normalized[$section]; + } else { + continue; + } + if ($targetText === '') { + continue; + } + + $matches = $this->matchPattern($targetText, $pattern); + foreach ($matches as $matchedText) { + $sentence = $this->extractSentenceContaining($targetText, $matchedText); + $hitScore = max(1, intval($rule['weight'] ?? 1)) * max(1, intval($rule['risk_level'] ?? 1)); + $hits[] = [ + 'section' => $section, + 'type' => '模板化表达', + 'sentence' => $sentence !== '' ? $sentence : $matchedText, + 'pattern' => $pattern, + 'score' => $hitScore, + 'remark' => (string) ($rule['remark'] ?? ''), + ]; + $ruleScore += $hitScore; + } + } + + return [ + 'hits' => $hits, + 'rule_score' => min(100, $ruleScore), + ]; + } + + /** + * @return array + */ + private function loadActiveRules(): array + { + if (self::$ruleCache !== null) { + return self::$ruleCache; + } + try { + self::$ruleCache = Db::name('ai_template_sentence') + ->where('status', 1) + ->field('id,sentence_pattern,section,language,risk_level,weight,remark') + ->order('section asc,id asc') + ->select(); + } catch (\Throwable $e) { + self::$ruleCache = []; + } + return self::$ruleCache; + } + + /** + * @return array + */ + private function matchPattern(string $text, string $pattern): array + { + // 支持 regex: 前缀;否则优先按正则,失败则按字面量匹配 + $raw = $pattern; + $forceRegex = false; + if (stripos($pattern, 'regex:') === 0) { + $raw = trim(substr($pattern, 6)); + $forceRegex = true; + } + + $out = $this->runPregMatchAll($text, '#' . $raw . '#iu'); + if (!empty($out) || $forceRegex) { + return $out; + } + + // 字面量回退(库内普通句子) + return $this->runPregMatchAll($text, '#' . preg_quote($raw, '#') . '#iu'); + } + + /** + * @return array + */ + private function runPregMatchAll(string $text, string $regex): array + { + $error = null; + set_error_handler(function ($errno, $errstr) use (&$error) { + $error = $errstr; + return true; + }); + $ok = preg_match_all($regex, $text, $matches); + restore_error_handler(); + if ($ok === false || $error !== null || empty($matches[0])) { + return []; + } + + $out = []; + foreach ($matches[0] as $match) { + $match = trim((string) $match); + if ($match !== '') { + $out[] = $match; + } + } + return $out; + } + + private function extractSentenceContaining(string $text, string $needle): string + { + $pos = mb_stripos($text, $needle); + if ($pos === false) { + return ''; + } + + $before = mb_substr($text, 0, $pos); + $after = mb_substr($text, $pos); + + $start = $before; + if (preg_match('/.*[.!?。!?]\s*/us', $before, $m)) { + $start = substr($before, strlen($m[0])); + } + + $sentence = $start . $after; + if (preg_match('/^(.+?[.!?。!?])/us', $sentence, $m)) { + return trim($m[1]); + } + if (preg_match('/^.{1,500}/us', $sentence, $m)) { + return trim($m[0]); + } + return trim(mb_substr($sentence, 0, 300)); + } +} diff --git a/application/common/AiTemplateSentenceService.php b/application/common/AiTemplateSentenceService.php new file mode 100644 index 00000000..1206e155 --- /dev/null +++ b/application/common/AiTemplateSentenceService.php @@ -0,0 +1,176 @@ +|null */ + private static $ruleCache; + + /** + * 按稿件分节统计模版句出现次数 + * + * @param array $sectionTexts 键:abstract/introduction/methods/results/discussion/conclusion + * @return array{total_matches:int,matched_rule_count:int,items:array,by_section:array} + */ + public function countInManuscript(array $sectionTexts): array + { + $rules = $this->loadActiveRules(); + if (empty($rules)) { + return [ + 'total_matches' => 0, + 'matched_rule_count' => 0, + 'items' => [], + 'by_section' => [], + ]; + } + + $normalizedSections = []; + foreach ($sectionTexts as $key => $text) { + $sectionKey = strtolower(trim((string) $key)); + if ($sectionKey === '') { + continue; + } + $normalizedSections[$sectionKey] = $this->normalizeText((string) $text); + } + + $allText = $this->normalizeText(implode("\n\n", array_filter($normalizedSections))); + + $items = []; + $bySection = []; + + foreach ($rules as $rule) { + $ruleSection = strtolower(trim((string) ($rule['section'] ?? ''))); + $pattern = trim((string) ($rule['sentence_pattern'] ?? '')); + if ($pattern === '') { + continue; + } + + $targetText = ''; + if ($ruleSection === 'all') { + $targetText = $allText; + } elseif (isset($normalizedSections[$ruleSection])) { + $targetText = $normalizedSections[$ruleSection]; + } else { + continue; + } + + if ($targetText === '') { + continue; + } + + $count = $this->countPatternMatches($targetText, $pattern); + if ($count <= 0) { + continue; + } + + $item = [ + 'id' => intval($rule['id'] ?? 0), + 'section' => $ruleSection, + 'sentence_pattern' => $pattern, + 'count' => $count, + 'risk_level' => intval($rule['risk_level'] ?? 0), + 'weight' => intval($rule['weight'] ?? 0), + 'remark' => (string) ($rule['remark'] ?? ''), + ]; + $items[] = $item; + + if (!isset($bySection[$ruleSection])) { + $bySection[$ruleSection] = [ + 'total_matches' => 0, + 'matched_rule_count' => 0, + 'items' => [], + ]; + } + $bySection[$ruleSection]['items'][] = $item; + $bySection[$ruleSection]['total_matches'] += $count; + $bySection[$ruleSection]['matched_rule_count']++; + } + + usort($items, function ($a, $b) { + if ($a['count'] !== $b['count']) { + return $b['count'] - $a['count']; + } + if ($a['weight'] !== $b['weight']) { + return $b['weight'] - $a['weight']; + } + return $a['id'] - $b['id']; + }); + + foreach ($bySection as &$sectionStats) { + usort($sectionStats['items'], function ($a, $b) { + if ($a['count'] !== $b['count']) { + return $b['count'] - $a['count']; + } + return $b['weight'] - $a['weight']; + }); + } + unset($sectionStats); + + $totalMatches = 0; + foreach ($items as $item) { + $totalMatches += intval($item['count']); + } + + return [ + 'total_matches' => $totalMatches, + 'matched_rule_count' => count($items), + 'items' => $items, + 'by_section' => $bySection, + ]; + } + + /** + * @return array + */ + private function loadActiveRules(): array + { + if (self::$ruleCache !== null) { + return self::$ruleCache; + } + + try { + self::$ruleCache = Db::name('ai_template_sentence') + ->where('status', 1) + ->field('id,sentence_pattern,section,language,risk_level,weight,remark,article_type,version') + ->order('section asc,id asc') + ->select(); + } catch (\Throwable $e) { + self::$ruleCache = []; + } + + return self::$ruleCache; + } + + private function normalizeText(string $text): string + { + return ManuscriptTextCleanService::clean($text); + } + + private function countPatternMatches(string $text, string $pattern): int + { + if ($text === '' || $pattern === '') { + return 0; + } + + $regex = '#' . $pattern . '#iu'; + $error = null; + set_error_handler(function ($errno, $errstr) use (&$error) { + $error = $errstr; + return true; + }); + $count = preg_match_all($regex, $text, $matches); + restore_error_handler(); + + if ($count === false || $error !== null) { + return 0; + } + + return intval($count); + } +} diff --git a/application/common/AiWritingRiskAnalysisService.php b/application/common/AiWritingRiskAnalysisService.php new file mode 100644 index 00000000..2f0f3382 --- /dev/null +++ b/application/common/AiWritingRiskAnalysisService.php @@ -0,0 +1,230 @@ +analyzeTemplateSentenceRisk($templateStats), + $this->analyzeRepeatedPhraseRisk($repeatedStats), + $this->analyzeSentenceLengthRisk($sentenceStats), + ]; + + $overallScore = 0; + $levelRank = ['low' => 1, 'medium' => 2, 'high' => 3]; + $maxRank = 1; + $flags = []; + + foreach ($dimensions as $dimension) { + $overallScore += intval($dimension['score'] ?? 0); + $rank = $levelRank[$dimension['level'] ?? 'low'] ?? 1; + if ($rank > $maxRank) { + $maxRank = $rank; + } + foreach ((array) ($dimension['flags'] ?? []) as $flag) { + if ($flag !== '') { + $flags[] = $flag; + } + } + } + + $overallLevel = array_search($maxRank, $levelRank, true) ?: 'low'; + + return [ + 'overall_level' => $overallLevel, + 'overall_score' => min(100, $overallScore), + 'summary' => $this->buildSummary($overallLevel, $dimensions, $flags), + 'dimensions' => $dimensions, + 'flags' => array_values(array_unique($flags)), + ]; + } + + private function analyzeTemplateSentenceRisk(array $stats): array + { + $items = (array) ($stats['items'] ?? []); + $totalMatches = intval($stats['total_matches'] ?? 0); + $matchedRuleCount = intval($stats['matched_rule_count'] ?? 0); + + $score = 0; + $flags = []; + foreach ($items as $item) { + $count = intval($item['count'] ?? 0); + $riskLevel = max(1, intval($item['risk_level'] ?? 1)); + $weight = max(1, intval($item['weight'] ?? 1)); + $score += $count * $riskLevel * $weight; + + if ($count > 0) { + $section = (string) ($item['section'] ?? ''); + $remark = trim((string) ($item['remark'] ?? '')); + $label = $remark !== '' ? $remark : (string) ($item['sentence_pattern'] ?? ''); + $flags[] = sprintf('%s段命中模版句:%s(%d次)', $section, $label, $count); + } + } + + $level = $this->scoreToLevel($score, 15, 40); + + return [ + 'key' => 'template_sentence', + 'name' => '模版句特征', + 'level' => $level, + 'score' => min(100, $score), + 'summary' => $totalMatches > 0 + ? sprintf('命中 %d 条模版句规则,共 %d 处', $matchedRuleCount, $totalMatches) + : '未检测到典型 AI 模版句', + 'metrics' => [ + 'total_matches' => $totalMatches, + 'matched_rule_count' => $matchedRuleCount, + ], + 'details' => $stats, + 'flags' => array_slice($flags, 0, 8), + ]; + } + + private function analyzeRepeatedPhraseRisk(array $stats): array + { + $items = (array) ($stats['suspicious_items'] ?? []); + if (empty($items)) { + $items = array_values(array_filter((array) ($stats['items'] ?? []), function ($item) { + return ($item['category'] ?? '') === 'suspicious'; + })); + } + $uniqueCount = count($items); + $expectedCount = count((array) ($stats['expected_topic_items'] ?? [])); + $topCount = 0; + $flags = []; + + foreach (array_slice($items, 0, 5) as $item) { + $count = intval($item['count'] ?? 0); + if ($count > $topCount) { + $topCount = $count; + } + if ($count >= 4) { + $flags[] = sprintf('可疑重复短语「%s」出现 %d 次', (string) ($item['phrase'] ?? ''), $count); + } + } + + $score = min(100, $uniqueCount * 3 + max(0, $topCount - 3) * 6); + $level = 'low'; + if ($uniqueCount >= 10 || $topCount >= 8) { + $level = 'high'; + } elseif ($uniqueCount >= 4 || $topCount >= 5) { + $level = 'medium'; + } + + $summary = '未发现可疑机械重复短语'; + if ($uniqueCount > 0) { + $summary = sprintf('发现 %d 组可疑重复短语,最高重复 %d 次', $uniqueCount, $topCount); + } elseif ($expectedCount > 0) { + $summary = sprintf('主题词正常重复 %d 组,未触发可疑短语', $expectedCount); + } + + return [ + 'key' => 'repeated_phrase', + 'name' => '重复短语', + 'level' => $level, + 'score' => $score, + 'summary' => $summary, + 'metrics' => [ + 'suspicious_count' => $uniqueCount, + 'expected_topic_count' => $expectedCount, + 'top_count' => $topCount, + ], + 'details' => $stats, + 'flags' => $flags, + ]; + } + + private function analyzeSentenceLengthRisk(array $stats): array + { + $avg = floatval($stats['avg'] ?? 0); + $std = floatval($stats['std'] ?? 0); + $max = intval($stats['max'] ?? 0); + $sentenceCount = intval($stats['sentence_count'] ?? 0); + + $score = 0; + $flags = []; + $level = 'low'; + + if ($sentenceCount >= 8) { + if ($avg >= 32) { + $score += 20; + $flags[] = sprintf('平均句长偏高(%.1f 词/句)', $avg); + } + if ($std > 0 && $std <= 5 && $avg >= 12 && $avg <= 28) { + $score += 25; + $flags[] = sprintf('句长分布过于均匀(均值 %.1f,标准差 %.1f)', $avg, $std); + } + if ($max >= 50) { + $score += 10; + $flags[] = sprintf('存在超长句(最长 %d 词)', $max); + } + } + + $level = $this->scoreToLevel($score, 10, 25); + + return [ + 'key' => 'sentence_length', + 'name' => '句长特征', + 'level' => $level, + 'score' => min(100, $score), + 'summary' => $sentenceCount > 0 + ? sprintf('平均句长 %.1f,标准差 %.1f,共 %d 句', $avg, $std, $sentenceCount) + : '正文句长数据不足', + 'metrics' => [ + 'avg' => $avg, + 'std' => $std, + 'max' => $max, + 'min' => intval($stats['min'] ?? 0), + 'sentence_count' => $sentenceCount, + ], + 'details' => $stats, + 'flags' => $flags, + ]; + } + + private function scoreToLevel(int $score, int $mediumThreshold, int $highThreshold): string + { + if ($score >= $highThreshold) { + return 'high'; + } + if ($score >= $mediumThreshold) { + return 'medium'; + } + return 'low'; + } + + private function buildSummary(string $overallLevel, array $dimensions, array $flags): string + { + $levelText = [ + 'low' => '低风险', + 'medium' => '中等风险', + 'high' => '较高风险', + ]; + $parts = [$levelText[$overallLevel] ?? '低风险']; + + foreach ($dimensions as $dimension) { + if (($dimension['level'] ?? 'low') === 'low') { + continue; + } + $parts[] = $dimension['name'] . '需关注'; + } + + if (!empty($flags)) { + $parts[] = '重点:' . $flags[0]; + } + + return implode(';', $parts); + } +} diff --git a/application/common/ManuscriptTextCleanService.php b/application/common/ManuscriptTextCleanService.php new file mode 100644 index 00000000..0b71e9fa --- /dev/null +++ b/application/common/ManuscriptTextCleanService.php @@ -0,0 +1,121 @@ + $sections + * @return array + */ + public static function cleanSections(array $sections): array + { + $cleaned = []; + foreach ($sections as $key => $text) { + $cleaned[$key] = self::clean((string) $text); + } + return $cleaned; + } + + private static function normalizeEncoding(string $text): string + { + if (!mb_check_encoding($text, 'UTF-8')) { + $text = mb_convert_encoding($text, 'UTF-8', 'GBK,GB2312,GB18030,ISO-8859-1'); + } + return $text; + } + + private static function normalizeQuotesAndDashes(string $text): string + { + return strtr($text, [ + '‘' => "'", + '’' => "'", + '“' => '"', + '”' => '"', + '„' => '"', + '‟' => '"', + '—' => '-', + '–' => '-', + '‑' => '-', + ' ' => ' ', + ]); + } + + private static function stripInvisibleChars(string $text): string + { + $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text); + $text = preg_replace('/[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}\x{FEFF}]/u', '', $text); + $text = str_replace([chr(0xC2) . chr(0xA0), ' '], ' ', $text); + return $text; + } + + private static function stripUrlsAndEmails(string $text): string + { + $text = preg_replace('/https?:\/\/\S+/iu', ' ', $text); + $text = preg_replace('/\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b/iu', ' ', $text); + return $text; + } + + private static function stripCitationMarkers(string $text): string + { + // [1] [1,2] [1-3] [1, 2-4] + $text = preg_replace('/\[\s*\d+(?:\s*[,,\-–—]\s*\d+)*\s*\]/u', ' ', $text); + // 上标残留数字(如 word 提取后的 ^1^) + $text = preg_replace('/\b\^\d+\^/u', ' ', $text); + return $text; + } + + private static function stripFigureTableTags(string $text): string + { + $text = preg_replace('/\((?:Fig(?:ure)?|Table|Tab\.)\s*[A-Za-z0-9.-]+\)/iu', ' ', $text); + $text = preg_replace('/\b(?:Fig(?:ure)?|Table|Tab\.)\s*[A-Za-z0-9.-]+\b/iu', ' ', $text); + return $text; + } + + private static function stripFootnoteMarkers(string $text): string + { + $text = preg_replace('/[*#†‡§]+\d*/u', ' ', $text); + return $text; + } + + /** + * 去除文献检索式、数据库字段等 Methods 常见残留 + */ + private static function stripSearchStrategyNoise(string $text): string + { + $text = preg_replace('/\btitle\s*[\/\-]\s*abstract\b/iu', ' ', $text); + $text = preg_replace('/\babstract\s*[\/\-]\s*title\b/iu', ' ', $text); + $text = preg_replace('/\bti\s*[\-\/]?\s*ab\b/iu', ' ', $text); + $text = preg_replace('/\bmesh\s+terms?\b/iu', ' ', $text); + $text = preg_replace('/\b(search\s+terms?|search\s+strategy)\b/iu', ' ', $text); + return $text; + } +}