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] =?UTF-8?q?=E5=8F=82=E8=80=83=E6=96=87=E7=8C=AE=E4=BD=9C?=
=?UTF-8?q?=E8=80=85=E5=A0=86=E5=8F=A0=20=E5=8F=82=E8=80=83=E6=96=87?=
=?UTF-8?q?=E7=8C=AE=E7=9B=B8=E5=85=B3=E6=80=A7=E6=A3=80=E6=B5=8B=20?=
=?UTF-8?q?=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 辅助写作风险检测
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 状态 queued
+ 任务 -
+ 更新 -
+
+
+
+
+ 排队中
+ 0%
+
+
+
current_step: -
+
+
+
+
+
+
+
+
+ 分维度风险
+
+
+ 章节评分
+
+
+ 编辑建议
+
+
+ 警告
+
+
+ 重点问题
+
+
+ 全部明细
+
+
+
+
+
+
+
+
+
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',
+ ],
];