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('~?jats:[^>]+>~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 档位
*/