From 68cf1867d896ff7de9de1dce79720918939ea3f7 Mon Sep 17 00:00:00 2001 From: wyn <1074145239@qq.com> Date: Fri, 22 May 2026 16:58:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=B2=E7=BB=8F=E5=AE=8C=E6=88=90=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E6=96=87=E7=AB=A0=E6=A0=A1=E5=AF=B9=E4=BA=86=EF=BC=8C?= =?UTF-8?q?=E4=BD=86=E6=8D=A2=E4=B8=AA=E6=96=87=E7=AB=A0id=E5=B0=B1?= =?UTF-8?q?=E6=8A=A5=E9=94=99=E4=BA=86=EF=BC=8C=E6=8E=92=E6=9F=A5=E5=89=8D?= =?UTF-8?q?=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/api/controller/Article.php | 8 +- application/api/job/ReferenceCheck.php | 56 +- application/api/job/ReferenceCheckTwo.php | 150 ++ application/common/ReferenceCheckService.php | 570 ++++++- application/common/service/LLMService.php | 1587 +++++++++++------- 5 files changed, 1755 insertions(+), 616 deletions(-) create mode 100644 application/api/job/ReferenceCheckTwo.php diff --git a/application/api/controller/Article.php b/application/api/controller/Article.php index b217e4c1..456fe59c 100644 --- a/application/api/controller/Article.php +++ b/application/api/controller/Article.php @@ -6641,8 +6641,9 @@ class Article extends Base } } public function checkOne(){ + $articleId = intval($this->request->param('article_id', 7414)); $svc = new ReferenceCheckService(); - $svc->checkOne(); + return jsonSuccess($svc->enqueueSecondPassByArticle($articleId)); } public function referenceCheckEnqueueArticleMain(){ $amId = 127448; @@ -6792,7 +6793,7 @@ class Article extends Base $citeStart = intval(isset($row['cite_tag_start']) ? $row['cite_tag_start'] : 0); $rowStatus = intval($row['status']); return array( - 'check_id' => intval($row['check_id']), + 'check_id' => intval(isset($row['id']) ? $row['id'] : (isset($row['check_id']) ? $row['check_id'] : 0)), 'article_id' => intval(isset($row['article_id']) ? $row['article_id'] : 0), 'am_id' => $amId, 'cite_group_key' => $amId . '_' . $citeStart, @@ -6806,7 +6807,8 @@ class Article extends Base 'text_end' => intval(isset($row['text_end']) ? $row['text_end'] : 0), 'status' => isset($statusMap[$rowStatus]) ? $statusMap[$rowStatus] : 'unknown', 'is_match' => intval($row['is_match']), - 'is_reasonable' => intval($row['is_match']) === 1, + 'can_support' => intval(isset($row['can_support']) ? $row['can_support'] : $row['is_match']), + 'is_reasonable' => intval(isset($row['can_support']) ? $row['can_support'] : $row['is_match']) === 1, 'confidence' => floatval($row['confidence']), 'reason' => isset($row['reason']) ? $row['reason'] : '', 'error_msg' => isset($row['error_msg']) ? $row['error_msg'] : '', diff --git a/application/api/job/ReferenceCheck.php b/application/api/job/ReferenceCheck.php index 704d692d..3b15e6a1 100644 --- a/application/api/job/ReferenceCheck.php +++ b/application/api/job/ReferenceCheck.php @@ -36,6 +36,9 @@ class ReferenceCheck try { $checkId = intval(isset($data['check_id']) ? $data['check_id'] : 0); + if ($checkId <= 0 && !empty($jobData['data']['check_id'])) { + $checkId = intval($jobData['data']['check_id']); + } $sClassName = get_class($this); $sRedisKey = "queue_job:{$sClassName}:{$checkId}"; $sRedisValue = uniqid() . '_' . getmypid(); @@ -61,45 +64,47 @@ class ReferenceCheck } try { - $mainInfo = Db::name('article_main')->where('am_id', $row['am_id'])->find(); - $contentA = trim($mainInfo['content']);//trim((string)(isset($row['origin_text']) ? $row['origin_text'] : '')); - if ($contentA === '' && !empty($row['content_a'])) { - $contentA = trim((string)$row['content_a']); - } - $contentB = trim((string)(isset($row['refer_text']) ? $row['refer_text'] : '')); + $svc = new ReferenceCheckService(); - if ($contentB === '' && intval($row['p_refer_id']) > 0) { + $contentA = $svc->resolveMainContentForJob($row); + $contentB = trim((string)(isset($row['refer_text']) ? $row['refer_text'] : '')); + $refer = null; + + if (intval($row['p_refer_id']) > 0) { $refer = Db::name('production_article_refer') ->where('p_refer_id', intval($row['p_refer_id'])) - ->where('status', 0) + ->where('state', 0) ->find(); - if ($refer) { - $contentB = (new ReferenceCheckService())->formatReferForLlm($refer); + if ($refer && $contentB === '') { + $contentB = $svc->formatReferForLlm($refer); } } if ($contentA === '' || $contentB === '') { - $this->markFailed($checkId, 'Missing content_a or reference text'); + $this->markFailed($checkId, 'Missing article_main.content or refer_text'); $job->delete(); return; } $llm = new LLMService(); - $llmResult = $llm->checkReference($contentA, $contentB); - $isMatch = !empty($llmResult['is_match']); + $llmResult = $llm->checkReference($contentA, $contentB, false); + $canSupport = $svc->parseLlmCanSupport($llmResult); + $confidence = floatval($llmResult['confidence']); - Db::name('article_reference_check_result')->where('id', $checkId)->update([ - 'is_match' => $isMatch ? 1 : 0, - 'confidence' => $llmResult['confidence'], - 'reason' => $llmResult['reason'], + $svc->updateCheckResult($checkId, [ + 'can_support' => $canSupport ? 1 : 0, + 'is_match' => $canSupport ? 1 : 0, + 'confidence' => $confidence, + 'reason' => isset($llmResult['reason']) ? $llmResult['reason'] : '', 'status' => 1, 'error_msg' => '', - 'updated_at' => date('Y-m-d H:i:s'), ]); + $svc->maybeEnqueueSecondPass($checkId, $confidence); + $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); if ($amId > 0) { - (new ReferenceCheckService())->syncAmRefCheckStatus($amId); + $svc->syncAmRefCheckStatus($amId); } $this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie, $sRedisValue); $job->delete(); @@ -127,11 +132,14 @@ class ReferenceCheck private function markFailed($checkId, $msg) { $row = Db::name('article_reference_check_result')->where('id', $checkId)->find(); - Db::name('article_reference_check_result')->where('id', $checkId)->update([ - 'status' => 2, - 'error_msg' => mb_substr($msg, 0, 500), - 'updated_at' => date('Y-m-d H:i:s'), - ]); + try { + (new ReferenceCheckService())->updateCheckResult($checkId, [ + 'status' => 2, + 'error_msg' => $msg, + ]); + } catch (\Exception $e) { + \think\Log::error('ReferenceCheck markFailed: ' . $e->getMessage()); + } $amId = empty($row) ? 0 : intval(isset($row['am_id']) ? $row['am_id'] : 0); if ($amId > 0) { (new ReferenceCheckService())->syncAmRefCheckStatus($amId); diff --git a/application/api/job/ReferenceCheckTwo.php b/application/api/job/ReferenceCheckTwo.php new file mode 100644 index 00000000..b28c9f6c --- /dev/null +++ b/application/api/job/ReferenceCheckTwo.php @@ -0,0 +1,150 @@ +oQueueJob = new QueueJob(); + $this->QueueRedis = QueueRedis::getInstance(); + } + + public function fire(Job $job, $data) + { + $this->oQueueJob->init($job); + + $rawBody = empty($job->getRawBody()) ? '' : $job->getRawBody(); + $jobData = empty($rawBody) ? [] : json_decode($rawBody, true); + $jobId = empty($jobData['id']) ? 'unknown' : $jobData['id']; + + $sRedisKey = ''; + $sRedisValue = ''; + + $this->oQueueJob->log("-----------队列任务开始-----------"); + $this->oQueueJob->log("当前任务ID: {$jobId}, 尝试次数: {$job->attempts()}"); + + try { + $checkId = intval(isset($data['check_id']) ? $data['check_id'] : 0); + if ($checkId <= 0 && !empty($jobData['data']['check_id'])) { + $checkId = intval($jobData['data']['check_id']); + } + $sClassName = get_class($this); + $sRedisKey = "queue_job_two:{$sClassName}:{$checkId}"; + $sRedisValue = uniqid() . '_' . getmypid(); + + if (!$this->oQueueJob->acquireLock($sRedisKey, $sRedisValue, $job)) { + return; + } + + if ($checkId <= 0) { + $job->delete(); + return; + } + + $row = Db::name('article_reference_check_result')->where('id', $checkId)->find(); + if (empty($row)) { + $job->delete(); + return; + } + +// if (intval($row['status']) === 1) { +// $job->delete(); +// return; +// } + + try { + $svc = new ReferenceCheckService(); + + $contentA = $svc->resolveMainContentForJob($row); + $referText = trim((string)(isset($row['refer_text']) ? $row['refer_text'] : '')); + $refer = null; + + if (intval($row['p_refer_id']) > 0) { + $refer = Db::name('production_article_refer') + ->where('p_refer_id', intval($row['p_refer_id'])) + ->where('state', 0) + ->find(); + } + + $payload = $svc->prepareRecheckPayload(is_array($refer) ? $refer : [], $referText); + $doiBlock = $payload['doi_block']; + + if ($contentA === '' || $referText === '') { + $this->markFailed($checkId, 'Missing article_main.content or refer_text'); + $job->delete(); + return; + } + $llm = new LLMService(); + $llmResult = $llm->checkReference($contentA, $referText, true, $doiBlock); + + $canSupport = $svc->parseLlmCanSupport($llmResult); + $tag = $payload['has_abstract'] + ? ('[Crossref复核' . ($payload['doi_used'] !== '' ? ' ' . $payload['doi_used'] : '') . ']') + : '[Crossref复核-无摘要]'; + $reason = $tag . ' ' . (isset($llmResult['reason']) ? $llmResult['reason'] : ''); + + $affected = $svc->updateCheckResult($checkId, [ + 'can_support' => $canSupport ? 1 : 0, + 'is_match' => $canSupport ? 1 : 0, + 'confidence' => floatval($llmResult['confidence']), + 'reason' => $reason, + 'status' => 1, + 'error_msg' => '', + ]); + $this->oQueueJob->log("Crossref复核写入 id={$checkId} affected={$affected} can_support=" . ($canSupport ? 1 : 0) . " confidence=" . floatval($llmResult['confidence'])); + + $amId = intval(isset($row['am_id']) ? $row['am_id'] : 0); + if ($amId > 0) { + $svc->syncAmRefCheckStatus($amId); + } + $this->QueueRedis->finishJob($sRedisKey, 'completed', $this->completedExprie, $sRedisValue); + $job->delete(); + $this->oQueueJob->log("任务执行成功 | 日志ID: {$sRedisKey}"); + } catch (\Exception $e) { + $this->oQueueJob->log('ReferenceCheckTwo error: ' . $e->getMessage()); + if ($job->attempts() >= 3) { + $this->markFailed($checkId, $e->getMessage()); + $job->delete(); + return; + } + $job->release(30); + } + } catch (\RuntimeException $e) { + $this->oQueueJob->handleRetryableException($e, $sRedisKey, $sRedisValue, $job); + } catch (\LogicException $e) { + $this->oQueueJob->handleNonRetryableException($e, $sRedisKey, $sRedisValue, $job); + } catch (\Exception $e) { + $this->oQueueJob->handleRetryableException($e, $sRedisKey, $sRedisValue, $job); + } finally { + $this->oQueueJob->finnal(); + } + } + + private function markFailed($checkId, $msg) + { + $row = Db::name('article_reference_check_result')->where('id', $checkId)->find(); + try { + (new ReferenceCheckService())->updateCheckResult($checkId, [ + 'status' => 2, + 'error_msg' => $msg, + ]); + } catch (\Exception $e) { + \think\Log::error('ReferenceCheckTwo markFailed: ' . $e->getMessage()); + } + $amId = empty($row) ? 0 : intval(isset($row['am_id']) ? $row['am_id'] : 0); + if ($amId > 0) { + (new ReferenceCheckService())->syncAmRefCheckStatus($amId); + } + } +} diff --git a/application/common/ReferenceCheckService.php b/application/common/ReferenceCheckService.php index 9aab409e..593f1548 100644 --- a/application/common/ReferenceCheckService.php +++ b/application/common/ReferenceCheckService.php @@ -3,6 +3,7 @@ namespace app\common; use think\Db; +use think\Env; use think\Queue; /** @@ -131,8 +132,39 @@ class ReferenceCheckService $this->setAmRefCheckStatus($amId, self::AM_STATUS_RUNNING); } - public function checkOne(){ - $this->pushJob(intval(724), 0); + /** + * 手工触发:对已完成且 confidence<=0.65 的记录入队 DOI 第二轮复核 + */ + public function enqueueSecondPassByArticle($articleId) + { + $articleId = intval($articleId); + if ($articleId <= 0) { + throw new \InvalidArgumentException('article_id is required'); + } + + $rows = Db::name('article_reference_check_result') + ->where('article_id', $articleId) + ->where('status', 1) + ->where('confidence', '<=', 0.65) + ->orderRaw('rand()') + ->limit(2) + ->select(); + + $checkIds2 = []; + $delay2 = 0; + foreach ($rows as $checkLog) { + $rowId = $this->resolveCheckRowId($checkLog); + if ($this->maybeEnqueueSecondPass($rowId, floatval($checkLog['confidence']))) { + $checkIds2[] = $rowId; + $delay2 += 1; + } + } + + return [ + 'article_id' => $articleId, + 'check_ids2' => $checkIds2, + 'queued' => count($checkIds2), + ]; } public function enqueueByArticle($articleId){ if ($articleId <= 0) { @@ -140,7 +172,7 @@ class ReferenceCheckService } $prod = Db::name('production_article') ->where('article_id', $articleId) - ->where('state', 0) + ->where('state', [0, 2]) ->find(); if (empty($prod)) { throw new \RuntimeException('production_article not found for article_id=' . $articleId); @@ -296,12 +328,78 @@ class ReferenceCheckService return isset($map[$status]) ? $map[$status] : 'unknown'; } + /** + * 表主键为 id(对外 API 参数名仍叫 check_id) + */ + public function resolveCheckRowId($row) + { + if (!is_array($row)) { + return 0; + } + if (isset($row['id']) && intval($row['id']) > 0) { + return intval($row['id']); + } + if (isset($row['check_id']) && intval($row['check_id']) > 0) { + return intval($row['check_id']); + } + return 0; + } + + /** + * 解析 LLM 返回的 is_match(兼容 bool / 0|1 / "true"|"false" 字符串) + */ + public function parseLlmIsMatch($value) + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return intval($value) === 1; + } + $s = strtolower(trim((string)$value)); + return in_array($s, ['1', 'true', 'yes', 'match', 'matched'], true); + } + + /** + * 写入单条校对结果(统一截断 reason/error_msg,避免 varchar(512) 导致 UPDATE 失败) + * + * @throws \RuntimeException + */ + public function updateCheckResult($checkId, array $fields) + { + $checkId = intval($checkId); + if ($checkId <= 0) { + throw new \InvalidArgumentException('invalid check id'); + } + + if (isset($fields['reason'])) { + $fields['reason'] = mb_substr(trim((string)$fields['reason']), 0, 512); + } + if (isset($fields['error_msg'])) { + $fields['error_msg'] = mb_substr(trim((string)$fields['error_msg']), 0, 512); + } + $fields['updated_at'] = date('Y-m-d H:i:s'); + + $exists = Db::name('article_reference_check_result')->where('id', $checkId)->find(); + if (empty($exists)) { + throw new \RuntimeException('article_reference_check_result not found, id=' . $checkId); + } + + $affected = Db::name('article_reference_check_result')->where('id', $checkId)->update($fields); + if ($affected === false) { + throw new \RuntimeException('article_reference_check_result update failed, id=' . $checkId); + } + + \think\Log::info('updateCheckResult id=' . $checkId . ' affected=' . intval($affected)); + return intval($affected); + } + public function getResult($checkId) { if ($checkId <= 0) { return null; } - $row = Db::name('article_reference_check_result')->where('check_id', $checkId)->find(); + $row = Db::name('article_reference_check_result')->where('id', $checkId)->find(); return $row ?: null; } @@ -435,7 +533,7 @@ class ReferenceCheckService 'ref_nos' => [], ]; } - $byAm[$amId]['contexts'][$ctxKey]['check_ids'][] = intval($row['check_id']); + $byAm[$amId]['contexts'][$ctxKey]['check_ids'][] = $this->resolveCheckRowId($row); $byAm[$amId]['contexts'][$ctxKey]['ref_nos'][] = $refNo; $reason = trim((string)$this->arrGet($row, 'reason', '')); if ($reason !== '') { @@ -501,7 +599,7 @@ class ReferenceCheckService $issueCount++; $issues[] = array( 'am_id' => $amId, - 'check_id' => intval($row['check_id']), + 'check_id' => $this->resolveCheckRowId($row), 'reference_no' => $num, 'reference_raw' => $inner, 'reason' => $rowReason, @@ -512,7 +610,7 @@ class ReferenceCheckService ENT_QUOTES, 'UTF-8' ); - return '' . $numMatch[0] . ''; }, @@ -627,6 +725,448 @@ class ReferenceCheckService return implode("\n", $parts); } + /** + * 仅使用 refer_doi 字段(二次 Crossref 摘要用) + */ + public function extractReferDoiOnly($refer) + { + if (!is_array($refer)) { + return ''; + } + $raw = trim((string)$this->arrGet($refer, 'refer_doi', '')); + if ($raw === '' || stripos($raw, 'not available') !== false) { + return ''; + } + $dois = $this->extractDoisFromString($raw); + return empty($dois) ? '' : $dois[0]; + } + + /** + * 根据 refer_doi 调用 Crossref works API 获取摘要(二次校对专用) + * + * @return array{text:string, has_abstract:bool, doi:string} + */ + public function fetchCrossrefAbstractByReferDoi($refer) + { + $doi = $this->extractReferDoiOnly($refer); + if ($doi === '') { + return ['text' => '', 'has_abstract' => false, 'doi' => '']; + } + + $crossref = new CrossrefService([ + 'mailto' => trim((string)Env::get('crossref_mailto', '')), + ]); + $block = $this->extractCrossrefBlock($doi, $crossref); + if ($block === null) { + return ['text' => '', 'has_abstract' => false, 'doi' => $doi]; + } + + return [ + 'text' => $block['text'], + 'has_abstract' => !empty($block['has_abstract']), + 'doi' => $doi, + ]; + } + + /** + * 解析 LLM 返回的 can_support + */ + public function parseLlmCanSupport($llmResult) + { + if (!is_array($llmResult)) { + return false; + } + if (array_key_exists('can_support', $llmResult)) { + return $this->parseLlmIsMatch($llmResult['can_support']); + } + return $this->parseLlmIsMatch(isset($llmResult['is_match']) ? $llmResult['is_match'] : false); + } + + /** + * 第一次校对:取 article_main.content(整节正文) + */ + public function resolveMainContentForJob(array $row, $maxChars = 8000) + { + $amId = intval($this->arrGet($row, 'am_id', 0)); + if ($amId <= 0) { + return ''; + } + $main = Db::name('article_main') + ->field('content') + ->where('am_id', $amId) + ->find(); + if (empty($main)) { + return ''; + } + + $text = trim((string)$this->arrGet($main, 'content', '')); + if ($text === '') { + return ''; + } + + $text = preg_replace('/\[([\d,\-\s]+)\]<\/blue>/', '[$1]', $text); + $text = strip_tags($text); + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $text = preg_replace('/\s+/u', ' ', $text); + $text = trim($text); + + $maxChars = max(500, intval($maxChars)); + if (mb_strlen($text) > $maxChars) { + $text = mb_substr($text, 0, $maxChars) . '...'; + } + + return $text; + } + + /** + * 引用处局部上下文(origin_text),供其它场景使用 + */ + public function resolveCitationContextForJob(array $row) + { + $text = trim((string)$this->arrGet($row, 'origin_text', '')); + if ($text === '') { + $text = trim((string)$this->arrGet($row, 'content_a', '')); + } + return $text; + } + + /** + * 从 refer 行提取标准 DOI(10.xxxx/...) + * + * 优先级:refer_content(原始引用文本里的 DOI 最贴近实际被引用的文献) + * > refer_doi > doi > doilink + */ + public function extractDoiFromRefer($refer) + { + $list = $this->extractAllDoiCandidatesFromRefer($refer); + return empty($list) ? '' : $list[0]; + } + + /** + * 返回 refer 行可能对应的全部 DOI 候选(去重,按优先级排序) + * + * 用于第二轮 DOI 复核场景:当 metadata 的 refer_doi 与原始引用文本里的 DOI + * 不一致时(数据漂移),优先尝试原始引用文本里的 DOI 抓真实摘要。 + * + * @return string[] + */ + public function extractAllDoiCandidatesFromRefer($refer) + { + if (!is_array($refer)) { + return []; + } + $ordered = [ + (string)$this->arrGet($refer, 'refer_content', ''), + (string)$this->arrGet($refer, 'refer_doi', ''), + (string)$this->arrGet($refer, 'doi', ''), + (string)$this->arrGet($refer, 'doilink', ''), + ]; + + $result = []; + foreach ($ordered as $raw) { + foreach ($this->extractDoisFromString($raw) as $doi) { + if (!in_array($doi, $result, true)) { + $result[] = $doi; + } + } + } + return $result; + } + + /** + * 从任意文本里抽取所有形如 10.xxxx/yyy 的 DOI + * @return string[] + */ + private function extractDoisFromString($text) + { + $text = trim((string)$text); + if ($text === '' || stripos($text, 'not available') !== false) { + return []; + } + + $dois = []; + + if (preg_match_all('~doi\.org/([^\s?#"\'<>]+)~i', $text, $m)) { + foreach ($m[1] as $cand) { + $cand = $this->trimDoiTail(trim($cand)); + if ($this->isValidDoi($cand)) { + $dois[] = $cand; + } + } + } + + if (preg_match_all('~\b(10\.\d{3,9}/[^\s?#"\'<>]+)~i', $text, $m)) { + foreach ($m[1] as $cand) { + $cand = $this->trimDoiTail(trim($cand)); + if ($this->isValidDoi($cand)) { + $dois[] = $cand; + } + } + } + + if ($dois === [] && strpos($text, '10.') === 0) { + $cand = $this->trimDoiTail($text); + if ($this->isValidDoi($cand)) { + $dois[] = $cand; + } + } + + return array_values(array_unique($dois)); + } + + private function trimDoiTail($doi) + { + return rtrim($doi, ".,;:)]}>\"'\\ \t\n\r"); + } + + private function isValidDoi($doi) + { + return (bool)preg_match('~^10\.\d{3,9}/[^\s]+$~i', (string)$doi); + } + + /** + * 通过 PubMed / Crossref 拉取 DOI 对应文献内容(本地 LLM 无法打开网页,须预先抓取) + * + * 行为: + * - 尝试 refer 行内所有 DOI 候选(refer_content > refer_doi > doi > doilink) + * - 优先采用第一个能拿到 abstract 的 DOI + * - PubMed 无摘要时回落到 Crossref raw 解析摘要(清理 JATS 标签) + * - 全部失败则返回空字符串(调用方据此跳过二次复核) + */ + public function fetchDoiLiteratureBlock($refer) + { + $candidates = $this->extractAllDoiCandidatesFromRefer($refer); + if (empty($candidates)) { + return ''; + } + + $pubmed = new PubmedService([ + 'email' => trim((string)Env::get('pubmed_email', '')), + 'tool' => trim((string)Env::get('pubmed_tool', 'tmrjournals')), + ]); + $crossref = new CrossrefService([ + 'mailto' => trim((string)Env::get('crossref_mailto', '')), + ]); + + $best = null; + $fallback = null; + + foreach ($candidates as $doi) { + $block = $this->buildDoiBlockFromSources($doi, $pubmed, $crossref); + if ($block === null) { + continue; + } + if (!empty($block['has_abstract'])) { + $best = $block; + break; + } + if ($fallback === null) { + $fallback = $block; + } + } + + $chosen = $best ?: $fallback; + if ($chosen === null) { + return ''; + } + return $chosen['text']; + } + + /** + * 拉单个 DOI 的真实内容,返回 ['text' => string, 'has_abstract' => bool] 或 null + */ + private function buildDoiBlockFromSources($doi, PubmedService $pubmed, CrossrefService $crossref) + { + $doi = trim((string)$doi); + if ($doi === '') { + return null; + } + + $pub = $pubmed->fetchByDoi($doi); + $pubAbstract = is_array($pub) ? trim((string)$this->arrGet($pub, 'abstract', '')) : ''; + + if (is_array($pub) && ($pubAbstract !== '' || trim((string)$this->arrGet($pub, 'title', '')) !== '')) { + $lines = ['Source: PubMed (DOI ' . $doi . ')']; + if (!empty($pub['title'])) { + $lines[] = 'Actual Title: ' . trim((string)$pub['title']); + } + if (!empty($pub['journal'])) { + $lines[] = 'Journal: ' . trim((string)$pub['journal']); + } + if (!empty($pub['year'])) { + $lines[] = 'Year: ' . trim((string)$pub['year']); + } + 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 ($pubAbstract !== '') { + $lines[] = 'Abstract: ' . $this->truncate($pubAbstract, 3500); + } + + if ($pubAbstract === '') { + $cr = $this->extractCrossrefBlock($doi, $crossref); + if ($cr !== null && $cr['has_abstract']) { + $lines[] = "\n--- Crossref 补充 ---\n" . $cr['text']; + return ['text' => implode("\n", $lines), 'has_abstract' => true]; + } + } + + return ['text' => implode("\n", $lines), 'has_abstract' => $pubAbstract !== '']; + } + + return $this->extractCrossrefBlock($doi, $crossref); + } + + /** + * 从 Crossref 拉取标题/期刊/作者/摘要(abstract 通常包裹 JATS XML,需清洗) + * @return array|null ['text' => string, 'has_abstract' => bool] + */ + private function extractCrossrefBlock($doi, CrossrefService $crossref) + { + $msg = $crossref->fetchWork($doi); + if (!is_array($msg)) { + return null; + } + + $summary = $crossref->fetchWorkSummary($doi); + if (!is_array($summary)) { + $summary = []; + } + + $lines = ['Source: Crossref api.crossref.org/works/' . rawurlencode($doi)]; + $title = isset($msg['title'][0]) ? trim((string)$msg['title'][0]) : trim((string)$this->arrGet($summary, 'title', '')); + if ($title !== '') { + $lines[] = 'Actual Title: ' . $title; + } + if (!empty($summary['joura'])) { + $lines[] = 'Journal: ' . trim((string)$summary['joura']); + } + if (!empty($summary['author_str'])) { + $lines[] = 'Authors: ' . trim((string)$summary['author_str']); + } + if (!empty($summary['dateno'])) { + $lines[] = 'Publication: ' . trim((string)$summary['dateno']); + } + if (!empty($summary['doilink'])) { + $lines[] = 'DOI Link: ' . trim((string)$summary['doilink']); + } + if (!empty($summary['is_retracted'])) { + $lines[] = 'Retraction: yes - ' . trim((string)$this->arrGet($summary, 'retract_reason', '')); + } + + $abstract = $this->cleanCrossrefAbstract((string)$this->arrGet($msg, 'abstract', '')); + $hasAbstract = $abstract !== ''; + if ($hasAbstract) { + $lines[] = 'Abstract: ' . $this->truncate($abstract, 3500); + } else { + $lines[] = 'Note: Crossref 未返回摘要,请结合标题/期刊/作者与正文谨慎判断。'; + } + + return ['text' => implode("\n", $lines), 'has_abstract' => $hasAbstract]; + } + + private function cleanCrossrefAbstract($raw) + { + $raw = trim((string)$raw); + if ($raw === '') { + return ''; + } + $raw = preg_replace('~]*>.*?~is', '', $raw); + $raw = preg_replace('~]*>~i', "\n", $raw); + $raw = preg_replace('~~i', '', $raw); + $raw = preg_replace('~]+>~i', '', $raw); + $raw = strip_tags($raw); + $raw = preg_replace('/[ \t]+/u', ' ', $raw); + $raw = preg_replace("/\r\n|\r/u", "\n", $raw); + $raw = preg_replace("/\n{2,}/u", "\n", $raw); + return trim($raw); + } + + private function truncate($text, $max) + { + $text = (string)$text; + if (mb_strlen($text) <= $max) { + return $text; + } + return mb_substr($text, 0, $max) . '...'; + } + + /** + * 第二次 DOI 复核数据准备:返回书目信息 + 真实抓取内容 + * + * @return array{refer_text:string, doi_block:string, has_abstract:bool, doi_used:string} + */ + public function prepareRecheckPayload($refer, $referText = '') + { + $base = trim($referText) !== '' ? trim($referText) : $this->formatReferForLlm($refer); + $cr = $this->fetchCrossrefAbstractByReferDoi($refer); + return [ + 'refer_text' => $base, + 'doi_block' => $cr['text'], + 'has_abstract' => $cr['has_abstract'], + 'doi_used' => $cr['doi'], + ]; + } + + /** + * 旧接口:拼接成单块文本(向后兼容,建议调用方改用 prepareRecheckPayload) + */ + public function formatReferForDoiRecheck($refer, $referText = '') + { + $payload = $this->prepareRecheckPayload($refer, $referText); + if ($payload['doi_block'] === '') { + return $payload['refer_text'] + . "\n\n【DOI 文献真实内容】\n未能从 PubMed/Crossref 获取该 DOI 的摘要或元数据,请依据书目条目与正文谨慎判断。"; + } + return $payload['refer_text'] + . "\n\n【Crossref 摘要(依据 Refer_doi 从 api.crossref.org/works 获取)】\n" + . $payload['doi_block']; + } + + /** + * 第一轮 confidence<=0.65 且能抓到 DOI 真实内容时,延迟入队第二轮复核 + * + * 跳过条件(避免无意义重跑得到相同结果): + * - check_id 不合法 / 一次置信度高于阈值 + * - refer 行不存在 + * - refer_doi 为空或 Crossref 未返回摘要 + */ + public function maybeEnqueueSecondPass($checkId, $confidence) + { + $checkId = intval($checkId); + $confidence = floatval($confidence); + if ($checkId <= 0 || $confidence > 0.65) { + return false; + } + + $row = Db::name('article_reference_check_result')->where('id', $checkId)->find(); + if (empty($row)) { + return false; + } + + $refer = null; + if (intval($row['p_refer_id']) > 0) { + $refer = Db::name('production_article_refer') + ->where('p_refer_id', intval($row['p_refer_id'])) + ->where('state', 0) + ->find(); + } + if (empty($refer) || $this->extractReferDoiOnly($refer) === '') { + return false; + } + + $cr = $this->fetchCrossrefAbstractByReferDoi($refer); + if (empty($cr['has_abstract'])) { + return false; + } + + $this->pushJob2($checkId, 5); + return true; + } + /** * 从 article_main.content 提取 blue 引用 */ @@ -1021,10 +1561,24 @@ class ReferenceCheckService } else { $jobId = Queue::push($jobClass, $data, self::QUEUE_NAME); } - var_dump("=====jobId:".$jobId); } catch (\Exception $e) { \think\Log::error('ReferenceCheck pushJob failed check_id=' . $checkId . ' ' . $e->getMessage()); throw $e; } } + private function pushJob2($checkId, $delaySeconds = 0) + { + $jobClass = 'app\api\job\ReferenceCheckTwo@fire'; + $data = ['check_id' => $checkId]; + try { + if ($delaySeconds > 0) { + $jobId = Queue::later($delaySeconds, $jobClass, $data, self::QUEUE_NAME); + } else { + $jobId = Queue::push($jobClass, $data, self::QUEUE_NAME); + } + } catch (\Exception $e) { + \think\Log::error('ReferenceCheckTwo pushJob failed check_id=' . $checkId . ' ' . $e->getMessage()); + throw $e; + } + } } diff --git a/application/common/service/LLMService.php b/application/common/service/LLMService.php index d8734596..01a755df 100644 --- a/application/common/service/LLMService.php +++ b/application/common/service/LLMService.php @@ -25,15 +25,18 @@ class LLMService } /** - * @param string $contextText 正文引用处句子 - * @param string $referText 参考文献条目(或 refer 格式化文本) + * @param string $contextText 正文引用处句子 + * @param string $referText 参考文献条目(或 refer 格式化文本) + * @param bool $isAgain 是否为 DOI 二次复核 + * @param string|null $doiBlock 可选:系统抓取到的 DOI 真实文献内容(仅二次复核使用) */ - public function checkReference($contextText, $referText) + public function checkReference($contextText, $referText, $isAgain = false, $doiBlock = null) { $fallback = [ - 'is_match' => false, - 'confidence' => 0.0, - 'reason' => 'LLM not configured or request failed', + 'can_support' => false, + 'is_match' => false, + 'confidence' => 0.0, + 'reason' => 'LLM not configured or request failed', ]; if ($this->url === '' || $this->model === '') { \think\Log::warning('ReferenceCheck LLM: url or model not configured'); @@ -42,26 +45,37 @@ class LLMService $contextText = trim($contextText); $referText = trim($referText); + $doiBlock = trim((string)$doiBlock); if ($contextText === '' || $referText === '') { return [ - 'is_match' => false, - 'confidence' => 0.0, - 'reason' => 'Empty citation context or reference text', + 'can_support' => false, + 'is_match' => false, + 'confidence' => 0.0, + 'reason' => 'Empty citation context or reference text', ]; } - if (mb_strlen($contextText) > 2000) { - $contextText = mb_substr($contextText, 0, 2000); + $maxContextLen = 8000; + if (mb_strlen($contextText) > $maxContextLen) { + $contextText = mb_substr($contextText, 0, $maxContextLen); } if (mb_strlen($referText) > 4000) { $referText = mb_substr($referText, 0, 4000); } + if (mb_strlen($doiBlock) > 4000) { + $doiBlock = mb_substr($doiBlock, 0, 4000); + } - $system = $this->buildReferenceCheckSystemPrompt3(); - \think\Log::info('system:' . $system); + if ($isAgain) { + $system = $this->buildReferenceCheckSecondPassPrompt(); + $user = $this->buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock); + } else { + $system = $this->buildReferenceCheckFirstPassPrompt(); + $user = $this->buildReferenceCheckFirstPassUserPrompt($contextText, $referText); + } - $user = $this->buildReferenceCheckUserPrompt($contextText, $referText); - \think\Log::info('user:' . $user); + \think\Log::info('ReferenceCheck system head: ' . mb_substr($system, 0, 200)); + \think\Log::info('ReferenceCheck user head: ' . mb_substr($user, 0, 600)); $payload = [ 'model' => $this->model, 'temperature' => 0, @@ -83,580 +97,131 @@ class LLMService return $fallback; } - $isMatch = !empty($parsed['is_match']); + $canSupport = $this->parseCanSupportFromParsed($parsed); $confidence = $this->snapReferenceCheckConfidence( $this->normalizeConfidence(isset($parsed['confidence']) ? $parsed['confidence'] : 0), - $isMatch + $canSupport + ); + $reason = $this->cleanReason((string)(isset($parsed['reason']) ? $parsed['reason'] : '')); + \think\Log::info( + 'ReferenceCheck result: can_support=' . ($canSupport ? '1' : '0') + . ', confidence=' . $confidence + . ', reason=' . $reason ); - \think\Log::info("confidence:".$confidence,[ - 'is_match' => $isMatch, - 'confidence' => $confidence, - 'reason' => $this->cleanReason((string)(isset($parsed['reason']) ? $parsed['reason'] : '')), - ]); return [ - 'is_match' => $isMatch, - 'confidence' => $confidence, - 'reason' => $this->cleanReason((string)(isset($parsed['reason']) ? $parsed['reason'] : '')), + 'can_support' => $canSupport, + 'is_match' => $canSupport, + 'confidence' => $confidence, + 'reason' => $reason, ]; } + + /** + * 解析 can_support;兼容 is_match 字段 + */ + private function parseCanSupportFromParsed(array $parsed) + { + if (array_key_exists('can_support', $parsed)) { + return $this->boolFromLlmValue($parsed['can_support']); + } + if (array_key_exists('is_match', $parsed)) { + return $this->boolFromLlmValue($parsed['is_match']); + } + return false; + } + + private function boolFromLlmValue($value) + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return intval($value) === 1; + } + $s = strtolower(trim((string)$value)); + return in_array($s, ['1', 'true', 'yes', 'support', 'supported'], true); + } + + /** 第一次校对:书目条目 vs 正文全文 */ + private function buildReferenceCheckFirstPassPrompt() + { + return <<<'PROMPT' +你是文献引用校对助手。判断【正文全文】与【参考文献书目】是否相关、能否用于支撑正文中的引用。 + +【核心原则:从宽判断,避免误杀】 +默认倾向 can_support=true。只要文献与正文不是「驴唇不对马嘴」,即判为相关、能支撑。 +不要求变量一致、不要求结论逐条对应、不要求研究设计相同。 + +【仅当以下情况才判 can_support=false(驴唇不对马嘴)】 +- 学科/主题完全无关(如正文讲深度学习聚类,文献是糖尿病步态检测)。 +- 明显张冠李戴(正文断言 A 疗法的效果,文献研究的是完全不同的 B 问题且无关联)。 +- 文献条目与正文讨论的对象/场景毫无交集,且无法作背景或理论引用。 + +【以下情况均应 can_support=true】 +- 同一大领域或相邻方向(如护理、心理、管理、医学、统计、AI 等相近子领域)。 +- 可作背景文献、综述性引用、理论或方法的一般性依据。 +- 表述略宽、略有概括、变量名不完全一致,但大方向说得通。 + +【confidence 固定档位(禁止其它小数)】 +can_support=true:0.65(有关联但较泛)/ 0.78 / 0.85 / 0.92 / 0.98(非常确定相关) +can_support=false:0.15(明确驴唇不对马嘴)/ 0.25 / 0.35 / 0.45(仅当实在无法建立任何合理关联) + +【输出】仅一行 minified JSON,无 markdown: +{"can_support":true|false,"is_match":true|false,"confidence":0.15|0.25|0.35|0.45|0.65|0.78|0.85|0.92|0.98,"reason":"30-80字简体中文"} +is_match 必须与 can_support 相同。 +PROMPT; + } + + private function buildReferenceCheckFirstPassUserPrompt($contextText, $referText) + { + return "【正文全文 article_main.content】\n" . $contextText + . "\n\n【参考文献书目 refer_text】\n" . $referText + . "\n\n请从宽判断:非驴唇不对马嘴即 can_support=true,只返回 JSON。"; + } + + /** 第二次校对:Crossref 摘要(Refer_doi) */ + private function buildReferenceCheckSecondPassPrompt() + { + return <<<'PROMPT' +你是文献引用二次校对助手。已根据 Refer_doi 从 Crossref(https://api.crossref.org/works/)获取摘要,请结合【正文全文】复核该文献是否相关。 + +【核心原则:与第一次相同,从宽判断】 +默认倾向 can_support=true。只要 Crossref 摘要(或书目)与正文不是驴唇不对马嘴,即判相关、能支撑。 +以【Crossref 摘要】为准;摘要与书目冲突时以摘要为准。 + +【仅当以下情况才判 can_support=false】 +- 摘要显示的研究主题/对象/方法与正文讨论内容完全风马牛不相及。 +- 典型驴唇不对马嘴、张冠李戴,且无法解释为背景或泛化引用。 + +【以下情况均应 can_support=true】 +- 摘要与正文属同领域或相近方向,能作背景、理论或方向性支撑。 +- 细节不完全一致,但不存在明显矛盾。 + +【无 Crossref 摘要时】 +结合 refer_text 从宽判断;非明显无关仍可 can_support=true,confidence 建议 0.65。 + +【confidence 固定档位(禁止其它小数)】 +can_support=true:0.65 / 0.78 / 0.85 / 0.92 / 0.98 +can_support=false:0.15 / 0.25 / 0.35 / 0.45 + +【输出】仅一行 minified JSON: +{"can_support":true|false,"is_match":true|false,"confidence":0.15|0.25|0.35|0.45|0.65|0.78|0.85|0.92|0.98,"reason":"30-80字简体中文"} +is_match 必须与 can_support 相同。 +PROMPT; + } + + private function buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock) + { + $doiBlock = trim((string)$doiBlock); + return "【正文全文 article_main.content】\n" . $contextText + . "\n\n【参考文献书目 refer_text】\n" . $referText + . "\n\n【Crossref 摘要】(Refer_doi → api.crossref.org/works/)\n" + . ($doiBlock !== '' ? $doiBlock : '(未获取到摘要,请结合 refer_text 从宽判断)') + . "\n\n非驴唇不对马嘴即 can_support=true,只返回 JSON。"; + } private function buildReferenceCheckSystemPrompt3() { - return <<<'PROMPT' -你是一名护理、医学与科研期刊的资深编辑,专门校对「正文引用句」与「对应参考文献条目」是否匹配。 - -你的职责是判断:作者在该引用位置引用的观点、数据、结论、方法、定义、理论或证据,是否能够被该条参考文献合理支撑。 - -你只能依据用户提供的两段文本判断,不得假设已阅读全文,不得联网,不得编造文献中未出现的信息。 - -【输入内容】 -你将收到: - -1. 正文引用句(引用位置附近的一句话或一段话) - -2. 当前对应的参考文献条目(仅当前编号,不是整篇参考文献列表) - -你必须严格只评估「当前这一条参考文献」与引用句的关系。 - -==================== -【核心判断目标】 - -判断: -正文中的核心论点、事实、数据、定义、护理措施、医学结论、研究发现、理论依据、政策依据、算法方法、统计方法、模型结构等,是否可由该条参考文献合理支撑。 - -你评估的是“引用是否成立”,不是“句子是否正确”。 - -==================== -【硬性约束(必须遵守)】 - -1. 只能依据用户提供的信息判断 -- 不得假设看过全文。 -- 不得联网。 -- 不得根据常识补全文献内容。 -- 不得根据作者、期刊名、热点方向脑补研究结果。 -- 不得把“可能研究了”视为“能够支撑”。 - -2. 严禁串号判断 -- 仅允许依据「当前引用句」与「当前参考文献条目」判断。 -- 严禁利用其它参考文献编号或上下文内容推断当前文献。 - -3. 不得关键词硬匹配 -禁止因为出现相同关键词就判匹配,例如: -“护理”“患者”“治疗”“效果”“心理”“机器学习”“深度学习”“模型”等。 - -必须重点判断: -- 对象是否一致 -- 疾病/场景是否一致 -- 人群是否一致 -- 干预方式是否一致 -- 方法学是否一致 -- 关键结论是否一致 - -4. 医学与科研错引从严 -若出现以下情况,优先判 false: - -- 同领域但具体疾病不同 -- 人群不同(儿童 vs 老年) -- 场景不同(ICU vs 普通病房) -- 干预方式不同 -- 指标或结局不同 -- 指南、综述、Meta、原始研究混用 -- 文献无法支撑正文中的强结论 - -例如: -正文: -“研究证实显著降低死亡率” - -文献: -“某护理模式应用观察” - -不得脑补效果成立,应从严判 false。 - -5. 特定证据类型必须一致 -若正文明确声明: - -- “随机对照研究显示” -- “Meta分析表明” -- “系统综述指出” -- “指南推荐” -- “专家共识建议” - -而文献条目显示证据类型不一致,应从严判 false。 - -6. 方法学引用必须严格一致(非常重要) -若正文明确引用某种: - -- 算法 -- 模型 -- 聚类方法 -- 分类方法 -- 深度学习架构 -- 统计方法 -- 数学技术 -- 数据处理方法 - -则文献必须与该方法存在明确合理关联。 - -例如: - -不匹配: -- fuzzy clustering ≠ deep learning -- random forest ≠ SVM -- CNN ≠ LSTM -- 聚类模型 ≠ 分类模型 -- 回归分析 ≠ 聚类分析 - -仅属于同一“人工智能/机器学习”大领域,不能视为匹配。 - -若方法体系明显不同: -优先判 false + confidence=0.15。 - -7. 信息不足从严 -若参考文献条目信息过少(仅作者+年份等): - -只有在能够建立明确关联时才可判 true。 - -无法建立明确关联: -判 false。 - -==================== -【评估步骤(按顺序在心里完成)】 - -第一步:主题域一致性 -判断正文核心主题与文献是否属于同一专业领域,包括: - -- 疾病 -- 患者群体 -- 护理问题 -- 医疗场景 -- 干预措施 -- 指标/结局 -- 理论模型 -- 政策/指南 -- 算法/统计方法 - -第二步:关键断言对齐 -判断正文中的核心断言是否能够被文献合理支撑。 - -允许: -- 合理概括 -- 轻度表述扩展 - -不允许: -- 张冠李戴 -- 过度推断 -- 用弱证据支撑强结论 -- 用相关性支撑因果性 -- 用观察研究支撑RCT级表述 -- 方法体系不一致 - -第三步:错引排查 -重点检查: - -- 疾病错 -- 人群错 -- 场景错 -- 方法错 -- 指标错 -- 研究类型错 -- 证据层级错 -- 算法体系错 - -==================== -【最终判定规则】 - -is_match(二选一) - -true: -满足以下全部条件: -- 主题明确相关 -- 核心对象基本一致 -- 方法或研究方向合理一致 -- 正文关键论点能够被文献支撑 -- 不存在明显错引风险 - -false: -满足任一情况: -- 主题无关 -- 对象不同 -- 疾病/场景不同 -- 方法体系明显不同 -- 核心结论对不上 -- 文献无法支撑正文强结论 -- 证据类型不一致 -- 无法建立明确合理关联 -- 信息不足无法确认 - -边界情况从严判 false。 - -==================== -【confidence 固定评分规则】 - -只能输出以下固定值之一: - -0.98 -0.92 -0.85 -0.78 -0.65 -0.45 -0.35 -0.25 -0.15 - -禁止输出任何其它数字。 - --------------------- -【true 档位】 - -0.98(几乎完全一致) -主题、对象、方法、核心结论高度一致。 - -0.92(高度匹配) -主题与关键论点明确一致,仅存在轻微概括。 - -0.85(较匹配) -主题和核心结论一致,但表述略宽。 - -0.78(基本匹配) -大方向一致,但存在轻微泛化或不精确。 - -0.65(边界匹配) -存在一定支撑关系,但结论略强或关联较弱。 - --------------------- -【false 档位】 - -0.45(人工复核) -信息不足、标题过泛、同领域但无法确认。 - -0.35(较可能错引) -同领域但对象、场景、结论存在明显偏差。 - -0.25(明显不匹配) -主题相关但核心论点明显不一致。 - -0.15(明确错引) -以下情况优先使用: - -- 主题无关 -- 方法体系明显不同 -- 典型张冠李戴 -- 完全无法支撑正文内容 - -例如: -正文讲 fuzzy clustering, -文献讲 hybrid deep learning, -应判: -false + 0.15。 - -==================== -【硬性规则】 - -- is_match=true 时: -confidence 只能是: -0.65 / 0.78 / 0.85 / 0.92 / 0.98 - -- is_match=false 时: -confidence 只能是: -0.15 / 0.25 / 0.35 / 0.45 - -禁止违反。 - -==================== -【评分稳定原则】 - -- 相同输入必须得到相同结果。 -- 优先依据“主题 + 核心断言”。 -- 不要被单个关键词误导。 -- 一句多引时,仅评价当前这一条文献。 -- 边界情况从严,降低漏报错引风险。 -- 方法学不一致时优先 false。 - -==================== -【reason 输出要求】 - -- 使用简体中文。 -- 长度控制在 30~80 字。 -- 只说明两件事: - 1)主题/对象/方法是否一致; - 2)核心论点是否能够支撑。 - -禁止模糊措辞: -- “可能有关” -- “看起来一致” -- “应该支持” -- “似乎” - -应明确表达: -一致 / 不一致 / 无法支撑。 - -==================== -【输出格式(绝对严格)】 - -仅输出一行 minified JSON。 - -禁止: -- markdown -- 代码块 -- 换行 -- 解释说明 -- 前后文字 - -格式: - -{"is_match":true|false,"confidence":0.15|0.25|0.35|0.45|0.65|0.78|0.85|0.92|0.98,"reason":"简体中文原因"} - -【示例输出】 - -{"is_match":false,"confidence":0.15,"reason":"正文讨论改进模糊聚类算法及聚类划分优化,而文献主题为基于步态加速度的糖尿病深度学习检测,研究方法与核心内容明显不符。"} -PROMPT; - } - private function buildReferenceCheckSystemPrompt() - { - return <<<'PROMPT' -你是一名护理与医学期刊的资深编辑,专门校对「正文引用句」与「对应参考文献条目」是否匹配。 - -你的职责是判断:作者在该引用位置引用的观点/数据/结论/方法/定义,是否能够被该条参考文献合理支撑。 - -你只能依据用户提供的两段文本判断,不得假设已阅读全文,不得联网,不得编造文献中未出现的信息。 - -【输入内容】 -你将收到: -1. 正文引用句(引用位置附近的一句话或一段话) -2. 当前对应的参考文献条目(仅当前编号,不是整篇参考文献列表) - -你必须严格只评估「当前这一条参考文献」与引用句的关系。 - -==================== -【核心判断目标】 -判断: -正文中的核心论点、事实、数据、定义、护理措施、医学结论、研究发现、理论依据、政策依据等,是否可由该条参考文献合理支撑。 - -你评估的是“引用是否成立”,不是“句子是否正确”。 - -==================== -【强制约束(必须遵守)】 - -1. 只能依据用户提供的信息判断 -- 不得假设你看过全文。 -- 不得根据常识补全文献内容。 -- 不得根据作者、期刊名或研究热点脑补研究结果。 -- 不得把“可能研究了”视为“能够支撑”。 - -2. 严禁串号判断 -- 仅允许依据「当前引用句」与「当前参考文献条目」判断。 -- 严禁利用其它参考文献编号或上下文内容推断当前文献。 - -3. 不得关键词硬匹配 -- 不得因为标题里出现相同关键词(如护理、患者、干预、效果、治疗、心理)就直接判定匹配。 -- 必须关注:对象、人群、疾病、干预方式、研究主题、核心结论是否一致。 - -4. 医学错引从严 -若出现以下情况,优先判定不匹配: -- 同一大领域但具体疾病/对象不同 -- 人群不同(儿童 vs 老年;ICU vs 普通病房等) -- 干预方式不同 -- 指标或结局不同 -- 把指南、综述、Meta分析、专家共识、原始研究混用导致支撑关系不成立 -- 文献无法合理支持正文中的强结论(如“显著改善”“明显降低”“证实”“优于”“危险因素”“因果关系”等) - -例如: -正文写: -“研究证实某护理显著降低死亡率” - -文献仅是: -“某护理模式应用观察” - -此时不得脑补效果成立,应从严判 false。 - -5. 特定证据类型必须一致 -若正文明确声明: -- “随机对照研究显示” -- “Meta分析表明” -- “指南推荐” -- “系统综述指出” -- “专家共识建议” - -而文献条目显示的证据类型不一致,应从严判 false。 - -6. 信息不足从严 -若参考文献条目信息过少(仅作者+年份等): -- 只有在能够建立明确合理关联时才判 true。 -- 无法建立明确关联时,判 false(confidence=0.35)。 - -7. 方法学引用严格一致 -若正文明确引用某一算法、模型、统计方法、聚类方法、 -深度学习架构、评估方法或数学技术: - -必须要求参考文献与该方法存在明确合理关联。 - -例如: -- fuzzy clustering ≠ deep learning -- random forest ≠ SVM -- CNN ≠ LSTM -- 聚类方法 ≠ 分类模型 - -仅属于同一“机器学习/人工智能”大领域, -不能视为匹配,应从严判 false。 - -若方法体系明显不同,优先判: -confidence=0.15 - -==================== -【评估步骤(按顺序在心里完成)】 - -第一步:主题域一致性 -判断正文句子的核心主题是否与文献属于同一专业领域,包括但不限于: -- 疾病/诊断 -- 护理问题 -- 患者人群 -- 医疗场景 -- 干预措施 -- 指标/结局 -- 理论模型 -- 政策/指南 - -第二步:关键断言对齐 -判断正文中的核心断言是否可被文献合理支撑: - -允许: -- 合理概括性引用 -- 轻度表述扩展 - -不允许: -- 张冠李戴 -- 过度推断 -- 用弱证据支撑强结论 -- 用相关性支撑因果性 -- 用观察研究支撑RCT级别表述 - -第三步:错引排查 -重点检查: -- 对象错 -- 疾病错 -- 场景错 -- 指标错 -- 方法错 -- 证据类型错 -- 研究层级不匹配 - -==================== -【最终判定规则】 - -is_match(二选一,必须一致) - -true: -满足以下全部条件: -- 主题明确相关 -- 核心对象基本一致 -- 正文关键论点能够被该文献合理支撑 -- 不存在明显错引风险 - -false: -任一情况满足即判 false: -- 主题无关 -- 具体对象明显不同 -- 核心结论对不上 -- 文献无法支撑正文强结论 -- 证据类型不匹配 -- 无法建立明确合理关联 -- 信息不足且无法确认 - -边界不清时,从严判 false。 - -==================== -【confidence 固定评分规则】 - -只能输出以下 6 个固定值之一: -0.95 -0.85 -0.75 -0.35 -0.25 -0.15 - -禁止输出: -0.5、0.6、0.7、0.8、0.9 等任何其它数字。 - -评分标准: - -0.95 -高度匹配: -主题、对象、研究方向、关键论点均明确对应。 - -0.85 -较匹配: -主题与核心论点一致,存在轻微概括,但仍合理支撑。 - -0.75 -基本匹配: -大方向一致,但有一定表述泛化或轻微不精确。 - -0.35 -存疑: -同领域但具体对象/结论不够明确; -或参考文献信息不足,建议人工复核。 - -0.25 -较可能错引: -主题相关但核心论点明显偏离; -对象、场景、结局存在明显差异。 - -0.15 -明确错引: -主题无关; -典型张冠李戴; -明显无法支撑正文内容。 - -硬性规则: -- is_match=true 时,confidence 只能是: -0.75 / 0.85 / 0.95 - -- is_match=false 时,confidence 只能是: -0.15 / 0.25 / 0.35 - -==================== -【评分稳定原则】 - -- 相同输入必须得到相同结论。 -- 优先依据“主题 + 核心断言”。 -- 不要被单个关键词误导。 -- 一句多引时,仅评价当前这一条文献。 -- 边界情况从严,降低漏报错引风险。 - -==================== -【reason 输出要求】 - -- 使用简体中文。 -- 仅说明: - 1)主题是否一致; - 2)核心论点是否能够支撑。 - -- 禁止模糊措辞: -“可能有关” -“看起来一致” -“应该支持” - -- 长度控制在 30~80 字。 - -==================== -【输出格式(绝对严格)】 - -仅输出一行 minified JSON。 -禁止 markdown。 -禁止代码块。 -禁止解释说明。 -禁止换行。 -禁止任何额外文字。 - -格式如下: - -{"is_match":true|false,"confidence":0.15|0.25|0.35|0.75|0.85|0.95,"reason":"简体中文原因说明"} - -【示例输出】 - -{"is_match":true,"confidence":0.95,"reason":"正文讨论的护理干预与文献研究对象、场景及核心结论一致,可合理支撑该引用。"} -PROMPT; + return $this->buildReferenceCheckFirstPassPrompt(); } /** @@ -704,7 +269,409 @@ PROMPT; {"is_match":true|false,"confidence":0.15|0.25|0.35|0.75|0.85|0.95,"reason":"1-2句简体中文,说明匹配或不匹配的关键依据"} PROMPT; } + private function buildReferenceCheckAgaintSystemPrompt() + { + return <<<'PROMPT' +你是一名护理、医学与科研期刊的资深编辑,专门校对「正文引用句」与「对应参考文献」是否真实匹配。 +你的职责是判断: + +作者在该引用位置引用的观点、数据、结论、方法、定义、理论或证据, + +是否能够被该参考文献 DOI 对应的真实文献内容合理支撑。 + +你必须执行: + +【第一轮:文献条目粗判】 ++ +【第二轮:DOI真实文献内容复核(最高优先级)】 + +最终结果以 DOI 页面实际文献内容为准。 + +不得仅凭标题、关键词或研究领域判定匹配。 + +==================== +【输入内容】 + +你将收到: + +1. 正文引用句(引用位置附近的一句话或一段话) + +2. 当前参考文献条目(仅当前编号) + +3. 文献元信息: +- Title +- Author +- Journal +- Year +- DOI +- DOI Link + +4. DOI 页面解析出的真实内容(最高优先级): +可能包括: + +- 实际标题 +- Abstract +- Keywords +- Objective +- Methods +- Participants +- Results +- Conclusion +- Study design +- Full metadata + +注意: + +DOI 页面内容优先级最高。 + +若 DOI 页面内容与参考文献条目存在冲突: + +必须以 DOI 页面真实显示内容为准。 + +==================== +【核心判断目标】 + +判断: + +正文中的核心论点、事实、数据、定义、护理措施、医学结论、研究发现、理论依据、政策依据、算法方法、统计方法、模型结构等, + +是否可由 DOI 对应的真实文献内容合理支撑。 + +你评估的是: + +“引用是否成立”。 + +不是: + +“正文是否正确”。 + +==================== +【硬性约束(必须遵守)】 + +1. 只能依据提供的信息判断 + +- 不得假设看过全文。 +- 不得联网到未提供的新网页。 +- 不得根据常识补全文献内容。 +- 不得根据作者、期刊名、热点方向脑补研究结果。 +- 不得把“可能研究了”视为“能够支撑”。 + +2. DOI真实内容优先(最高优先级) + +必须优先依据: + +- DOI摘要 +- DOI方法 +- DOI研究对象 +- DOI结果 +- DOI结论 + +判断是否支撑正文。 + +禁止: + +仅因为标题相似或关键词重叠就判 true。 + +例如: + +正文: +“研究证实显著降低焦虑” + +DOI摘要未提焦虑改善结果: + +必须 false。 + +3. 严禁串号判断 + +- 仅允许依据当前引用句与当前参考文献。 +- 严禁利用其它参考文献编号或上下文推断当前文献。 + +4. 不得关键词硬匹配 + +禁止因为出现相同关键词就判匹配,例如: + +“护理”“患者”“治疗”“效果”“心理” +“机器学习”“深度学习”“模型”等。 + +必须重点判断: + +- 对象是否一致 +- 疾病/场景是否一致 +- 人群是否一致 +- 干预方式是否一致 +- 方法学是否一致 +- 关键结论是否一致 + +5. 医学与科研错引从严 + +若 DOI 内容出现以下情况: + +优先判 false: + +- 同领域但疾病不同 +- 人群不同(儿童 vs 老年) +- 场景不同(ICU vs 普通病房) +- 干预方式不同 +- 指标或结局不同 +- 指南、综述、Meta、原始研究混用 +- 文献无法支撑正文中的强结论 + +例如: + +正文: +“研究证实显著降低死亡率” + +DOI: +仅描述护理模式应用观察。 + +不得脑补效果成立。 + +应从严判 false。 + +6. 特定证据类型必须一致 + +正文明确声明: + +- “随机对照研究显示” +- “Meta分析表明” +- “系统综述指出” +- “指南推荐” +- “专家共识建议” + +若 DOI 内容显示证据类型不一致: + +应从严判 false。 + +7. 方法学引用必须严格一致(极重要) + +若正文明确引用: + +- 算法 +- 模型 +- 聚类方法 +- 分类方法 +- 深度学习架构 +- 统计方法 +- 数学技术 +- 数据处理方法 + +DOI 内容必须与该方法存在明确合理关联。 + +例如: + +不匹配: + +- fuzzy clustering ≠ deep learning +- random forest ≠ SVM +- CNN ≠ LSTM +- 聚类模型 ≠ 分类模型 +- 回归分析 ≠ 聚类分析 + +仅属于同一“大领域(AI/ML)” + +不能视为匹配。 + +若方法体系明显不同: + +优先判: + +false + confidence=0.15 + +8. DOI 内容中的核心变量必须一致(新增重点) + +若正文讨论: + +- 心理资本 +- 工作流 +- 组织支持 +- 焦虑 +- 压力 +- 满意度 +- 护理能力 +- 风险预测 + +必须检查 DOI 内容是否真正研究该变量及其关系。 + +例如: + +正文: +“心理资本影响工作流” + +DOI: +研究组织支持与工作流。 + +即使都属于护士心理研究: + +仍应 false。 + +9. 信息不足从严 + +若: + +- DOI打不开 +- DOI无摘要 +- DOI内容不足 +- 无法建立明确关联 + +只有明确支撑时才判 true。 + +否则: + +false。 + +==================== +【评估步骤(按顺序在心里完成)】 + +第一步:DOI内容优先理解 +先判断 DOI 实际研究: + +- 谁(对象) +- 什么问题(主题) +- 怎么研究(方法) +- 得出什么(结果/结论) + +第二步:主题域一致性 + +检查正文与 DOI 文献是否属于同一: + +- 疾病 +- 患者群体 +- 护理问题 +- 医疗场景 +- 干预措施 +- 指标/结局 +- 理论模型 +- 算法/统计方法 + +第三步:关键断言对齐 + +判断正文核心断言是否真正被 DOI 内容支撑。 + +允许: + +- 合理概括 +- 轻度扩展 + +不允许: + +- 张冠李戴 +- 过度推断 +- 用相关性支撑因果性 +- 用弱证据支撑强结论 +- 方法体系不一致 + +第四步:错引排查 + +重点检查: + +- 疾病错 +- 人群错 +- 场景错 +- 方法错 +- 指标错 +- 研究类型错 +- 变量关系错 +- 算法体系错 + +==================== +【最终判定规则】 + +is_match(二选一) + +true: + +满足以下全部条件: + +- 主题明确相关 +- 核心对象基本一致 +- 方法或研究方向合理一致 +- DOI内容支持正文关键论点 +- 不存在明显错引风险 + +false: + +满足任一情况: + +- 主题无关 +- 对象不同 +- 疾病/场景不同 +- 方法体系明显不同 +- 核心变量关系不同 +- DOI内容无法支撑正文结论 +- 证据类型不一致 +- 无法建立明确合理关联 +- 信息不足无法确认 + +边界情况从严判 false。 + +==================== +【confidence 固定评分规则】 + +只能输出以下固定值之一: + +0.98 +0.92 +0.85 +0.78 +0.65 +0.45 +0.35 +0.25 +0.15 + +禁止输出其它数字。 + +硬规则: + +is_match=true: +只能: +0.65 / 0.78 / 0.85 / 0.92 / 0.98 + +is_match=false: +只能: +0.15 / 0.25 / 0.35 / 0.45 + +DOI内容与正文明显冲突: +优先: +0.15 + +==================== +【reason 输出要求】 + +- 使用简体中文 +- 长度30~80字 +- 仅说明: +1)DOI文献研究内容; +2)是否支撑正文核心论点。 + +禁止: + +“可能” +“应该” +“看起来” +“似乎” + +必须明确表达: +一致 / 不一致 / 无法支撑。 + +==================== +【输出格式(绝对严格)】 + +仅输出一行 minified JSON。 + +禁止: +- markdown +- 代码块 +- 换行 +- 解释说明 +- 前后文字 + +格式: + +{"is_match":true|false,"confidence":0.15|0.25|0.35|0.45|0.65|0.78|0.85|0.92|0.98,"reason":"简体中文原因"} +PROMPT; + } private function buildReferenceCheckUserPrompt($contextText, $referText) { return "【正文引用句】(含该处引用所要支撑的观点,可能为中文或英文)\n" @@ -714,6 +681,464 @@ PROMPT; . "\n\n请按 system 中的步骤与评分表完成校对,只返回 JSON。"; } + /** + * 二次 DOI 复核 system prompt: + * - 强调输入中的"DOI 真实内容"已由系统抓取,模型不可自行联网 + * - 处理 metadata(标题/作者)与 refer_content/DOI 抓取内容不一致的情况 + * - confidence 档位与一次校对保持一致 + */ + private function buildReferenceCheckRecheckSystemPrompt() + { + return <<<'PROMPT' +你是一名护理、医学与科研期刊的资深编辑,正在执行【初稿 DOI 文献复核】。 + +一次粗判(仅依据书目条目)已经给出较低置信度(≤0.65)。 + +你的职责是: + +依据系统提供的【DOI 真实文献内容】重新判断: + +正文引用位置的观点、结论、方法、数据或理论, + +是否能够被 DOI 对应的真实文献“基本合理支撑”。 + +你的目标是: + +优先识别真正错引, + +同时避免误杀“合理但非完全一致”的引用。 + +注意: + +初稿校对允许: + +- 背景研究支撑 +- 理论依据支撑 +- 同方向研究支撑 +- 合理概括 +- 轻度表述扩展 + +不要求: + +正文与 DOI 摘要逐字对应。 + +==================== +【输入结构】 + +User 消息中会出现三个块: + +1.【正文引用句】 + +作者希望被该引用支撑的: + +观点、方法、数据、结论或理论。 + +2.【参考文献条目(书目)】 + +可能包含: + +- Title +- Author +- Journal +- Year +- DOI +- Reference + +注意: + +书目可能存在: + +- 错 DOI +- 错标题 +- 错作者 +- 元数据漂移 + +不能仅依据书目判断。 + +3.【DOI 真实文献内容(最高优先级)】 + +来源: + +Source: PubMed +或 +Source: Crossref + +可能包含: + +- 真正标题 +- Abstract +- Methods +- Results +- Conclusion +- MeSH +- Publication Type + +该内容已由系统抓取, + +视为: + +“真实文献内容”。 + +禁止联网。 +禁止自行打开 DOI。 +禁止猜测未提供字段。 + +==================== +【判断优先级(必须遵守)】 + +A. +DOI 内容最高优先级 + +若 DOI 内容存在: + +必须以其为准。 + +即使: + +书目 Title / Author 与 DOI 冲突, + +也以 DOI 内容为准。 + +==================== +B. +DOI 有摘要 + +优先依据: + +- 研究对象 +- 核心变量 +- 方法 +- 结果 +- 结论 + +判断是否支撑正文。 + +允许: + +- 合理概括 +- 背景研究支撑 +- 同方向研究支撑 +- 理论依据支撑 +- 轻度扩展 + +不要求: + +逐字一致。 + +==================== +C. +DOI 仅有标题,无摘要 + +仅当标题与正文存在: + +明确语义关联 + +才可判: + +true + 0.65 + +否则: + +优先: + +false + 0.45 + +(人工复核) + +不要轻易判: + +0.15。 + +==================== +D. +DOI 获取失败 + +若: + +- 无摘要 +- 无核心信息 +- 抓取失败 + +不能直接判 true。 + +也不要轻易判错引。 + +优先: + +false + 0.45 + +(信息不足,人工复核) + +==================== +【允许 true 的情况(重要)】 + +以下情况允许 true: + +1. +DOI 摘要直接支撑正文核心观点。 + +2. +DOI 文献属于: + +- 背景研究 +- 理论依据 +- 同方向研究 + +即使: + +对象、变量或场景存在轻微差异, + +但研究方向一致, + +仍可: + +0.65 / 0.78。 + +例如: + +正文: +工作流与职业发展相关。 + +DOI: +工作流与心理资本关系。 + +可作为背景研究支撑: + +true + 0.65。 + +3. +正文属于概括性表达, + +DOI 文献能支撑主要方向。 + +==================== +【优先 false 的情况】 + +以下情况优先 false: + +1. +主题明显无关。 + +2. +研究对象明显不同。 + +例如: + +- 儿童 vs 老年 +- ICU vs 普通病房 + +3. +疾病 / 场景明显不同。 + +4. +方法体系明显冲突 +(仅限明确方法引用)。 + +仅当正文明确讨论: + +- 算法 +- 模型 +- 聚类 +- 分类 +- 深度学习架构 +- 统计方法 +- 数据处理方法 + +时, + +要求方法一致。 + +例如: + +- fuzzy clustering ≠ deep learning +- CNN ≠ LSTM +- 聚类 ≠ 分类 +- random forest ≠ SVM + +此类: + +优先: + +false + 0.15。 + +注意: + +若正文只是: + +背景研究、 +相关工作、 +理论依据, + +不要因方法不同直接 false。 + +5. +正文强结论无法支撑。 + +正文出现: + +- 显著改善 +- 显著降低 +- 证实 +- 优于 +- 危险因素 +- 有效预测 +- 中介作用 + +但 DOI 摘要未提供对应结果: + +优先 false。 + +6. +正文明确: + +- RCT +- Meta分析 +- 系统综述 +- Guideline + +但 DOI 类型明显不一致。 + +==================== +【confidence 固定评分规则】 + +只能输出: + +0.98 +0.92 +0.85 +0.78 +0.65 +0.45 +0.35 +0.25 +0.15 + +禁止其它数字。 + +-------------------- +【true 档位】 + +0.98 +DOI 对象、方法、结论与正文高度一致。 + +0.92 +DOI 明确支撑正文关键论点。 + +0.85 +DOI 支撑核心观点, +存在轻微概括。 + +0.78 +研究方向一致, +能够合理支撑正文。 + +0.65 +边界匹配: + +可作为背景研究、 +理论依据、 +同方向研究支撑。 + +建议人工复核。 + +-------------------- +【false 档位】 + +0.45 +信息不足、 +无摘要、 +标题过泛、 +无法确认。 + +建议人工复核。 + +0.35 +同领域但对象、变量或结论偏差明显。 + +0.25 +主题相关但核心观点无法支撑。 + +0.15 +明确错引: + +- DOI 内容明显无关 +- 方法体系冲突 +- 张冠李戴 +- 强结论明显无法成立 + +==================== +【硬性规则】 + +is_match=true: + +只能: +0.65 / 0.78 / 0.85 / 0.92 / 0.98 + +is_match=false: + +只能: +0.15 / 0.25 / 0.35 / 0.45 + +==================== +【评分稳定原则】 + +- 相同输入得到相同结果。 +- 优先主题 + 核心论点。 +- 不因关键词重叠误判。 +- 一句多引仅评价当前文献。 +- 模糊情况优先人工复核。 +- 不轻易误杀合理引用。 + +==================== +【reason 输出要求】 + +简体中文。 + +30~80字。 + +必须说明: + +1)DOI 文献研究什么; + +2)是否支撑正文核心观点; + +3)支撑点或冲突点是什么。 + +禁止: + +“可能” +“应该” +“似乎” +“看起来” + +必须明确表达: + +一致 / 不一致 / 可支撑 / 无法支撑。 + +==================== +【输出格式(严格)】 + +仅输出一行 minified JSON。 + +禁止: + +- markdown +- 代码块 +- 换行 +- 解释说明 +- 前后文字 + +格式: + +{"is_match":true|false,"confidence":0.15|0.25|0.35|0.45|0.65|0.78|0.85|0.92|0.98,"reason":"简体中文原因"} +PROMPT; + } + + private function buildReferenceCheckRecheckUserPrompt($contextText, $referText, $doiBlock) + { + return $this->buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock); + } + /** * 与 buildReferenceCheckSystemPrompt3 一致的 confidence 档位 */