\s*\[/', $after)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * 段落起始(HTML / 换行),避免英文多句段落只取到最后一个句号后的一句
+ */
+ private function findParagraphStart($content, $tagStart)
+ {
+ $search = substr($content, 0, max(0, $tagStart));
+ if ($search === '') {
+ return 0;
+ }
+
+ $best = 0;
+
+ if (preg_match_all('/]*>/i', $search, $m, PREG_OFFSET_CAPTURE)) {
+ $last = end($m[0]);
+ $best = max($best, $last[1] + strlen($last[0]));
+ }
+ if (preg_match_all('/<\/p>\s*/i', $search, $m, PREG_OFFSET_CAPTURE)) {
+ $last = end($m[0]);
+ $best = max($best, $last[1] + strlen($last[0]));
+ }
+ if (preg_match_all('/
\s*/i', $search, $m, PREG_OFFSET_CAPTURE)) {
+ $last = end($m[0]);
+ $best = max($best, $last[1] + strlen($last[0]));
+ }
+
+ $pos = strrpos($search, "\n\n");
+ if ($pos !== false) {
+ $best = max($best, $pos + 2);
+ }
+ $pos = strrpos($search, "\n");
+ if ($pos !== false) {
+ $best = max($best, $pos + 1);
+ }
+
+ return $best;
+ }
+
+ /**
+ * 段落过长时从引用处向前截取上限,避免单次 LLM 上下文过大
+ */
+ private function capContextStartBeforeTag($content, $tagStart, $paragraphStart, $maxBytes = 2500)
+ {
+ if ($tagStart - $paragraphStart <= $maxBytes) {
+ return $paragraphStart;
+ }
+
+ $start = $tagStart - $maxBytes;
+ $slice = substr($content, $start, $tagStart - $start);
+ if (preg_match('/[.!?。!?]\s+/u', $slice, $m, PREG_OFFSET_CAPTURE)) {
+ $rel = $m[0][1] + strlen($m[0][0]);
+ return $start + $rel;
+ }
+
+ return max($paragraphStart, $start);
+ }
+
+ private function findSentenceStart($content, $position)
+ {
+ $start = 0;
+ foreach (['.', '。', '!', '?', "\n"] as $delimiter) {
+ $pos = strrpos(substr($content, 0, $position), $delimiter);
+ if ($pos !== false && $this->isSentenceDelimiterAt($content, $pos, $delimiter)) {
+ $start = max($start, $this->utf8CharEnd($content, $pos));
+ }
+ }
+ return $start;
+ }
+
+ /**
+ * @param int $searchFrom 从该字节位置起查找句末
+ * @param int $tagEnd 引用标签结束位置;用于跳过
后紧跟的孤立句号
+ */
+ private function findSentenceEnd($content, $searchFrom, $tagEnd = 0)
+ {
+ $length = strlen($content);
+ $minPos = max(0, $searchFrom);
+
+ while ($minPos < $length) {
+ $endPositions = [];
+ foreach (['.', '。', '!', '?', "\n"] as $delimiter) {
+ $pos = strpos($content, $delimiter, $minPos);
+ if ($pos !== false && $this->isSentenceDelimiterAt($content, $pos, $delimiter)) {
+ $endPositions[] = $this->utf8CharEnd($content, $pos);
+ }
+ }
+ if (empty($endPositions)) {
+ return $length;
+ }
+
+ $end = min($endPositions);
+ if ($tagEnd <= 0 || $end <= $tagEnd) {
+ return $end;
+ }
+
+ $gap = substr($content, $tagEnd, $end - $tagEnd);
+ $gapText = trim(strip_tags(preg_replace(self::BLUE_TAG_REGEX, '', $gap)));
+ if ($gapText !== '' && !$this->isOnlyPunctuationOrSpace($gapText)) {
+ return $end;
+ }
+
+ $minPos = $end;
+ }
+
+ return $length;
+ }
+
+ /**
+ * 已入库记录按文献编号正序入队(同号按 am_id、正文位置稳定排序)
+ *
+ * @param array $rows 元素含 check_id、reference_no,可选 am_id、text_start
+ */
+ private function pushJobsSortedByReferenceNo(array $rows)
+ {
+ if (empty($rows)) {
+ return [];
+ }
+
+ usort($rows, function ($a, $b) {
+ if ($a['reference_no'] !== $b['reference_no']) {
+ return $a['reference_no'] - $b['reference_no'];
+ }
+ $amA = isset($a['am_id']) ? intval($a['am_id']) : 0;
+ $amB = isset($b['am_id']) ? intval($b['am_id']) : 0;
+ if ($amA !== $amB) {
+ return $amA - $amB;
+ }
+ $posA = isset($a['text_start']) ? intval($a['text_start']) : 0;
+ $posB = isset($b['text_start']) ? intval($b['text_start']) : 0;
+ return $posA - $posB;
+ });
+
+ $checkIds = [];
+ $delay = 0;
+ foreach ($rows as $row) {
+ $checkId = intval($row['check_id']);
+ $checkIds[] = $checkId;
+ $this->pushJob($checkId, $delay);
+ $delay++;
+ }
+
+ return $checkIds;
+ }
+
+ private function pushJob($checkId, $delaySeconds = 0)
+ {
+ $checkId = intval($checkId);
+ $this->clearReferenceCheckQueueLock($checkId);
+ $jobClass = 'app\api\job\ReferenceCheck@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('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
new file mode 100644
index 00000000..69f5e61c
--- /dev/null
+++ b/application/common/service/LLMService.php
@@ -0,0 +1,1271 @@
+url = trim((string)Env::get('promotion.promotion_llm_url', ''));
+ $this->model = trim((string)Env::get('promotion.promotion_llm_model', ''));
+ $this->apiKey = trim((string)Env::get('promotion.promotion_llm_api_key', ''));
+ // 引用校对 system 提示词较长,请求常超过 30s,至少 120s
+ $this->timeout = max(120, intval(Env::get('promotion.promotion_llm_timeout', 120)));
+ }
+
+ /**
+ * @param string $contextText 正文引用处句子
+ * @param string $referText 参考文献条目(或 refer 格式化文本)
+ * @param bool $isAgain 是否为 DOI 二次复核
+ * @param string|null $doiBlock 可选:系统抓取到的 DOI 真实文献内容(仅二次复核使用)
+ */
+ public function checkReference($contextText, $referText, $isAgain = false, $doiBlock = null)
+ {
+ // request_failed=true 表示"LLM 通讯/解析层面的失败"(可重试,区别于业务上的"未命中");
+ // 上游 runReferenceCheckOnce 会据此把 DB.status 置为 2(失败) 并抛异常触发队列重试
+ $fallback = [
+ 'can_support' => false,
+ 'is_match' => false,
+ 'confidence' => 0.0,
+ 'reason' => 'LLM not configured or request failed',
+ 'request_failed' => true,
+ ];
+ if ($this->url === '' || $this->model === '') {
+ \think\Log::warning('ReferenceCheck LLM: url or model not configured');
+ return $fallback;
+ }
+
+ $contextText = trim($contextText);
+ $referText = trim($referText);
+ $doiBlock = trim((string)$doiBlock);
+ if ($contextText === '' || $referText === '') {
+ // 空文本是入参问题,不是 LLM 故障,不需要重试
+ return [
+ 'can_support' => false,
+ 'is_match' => false,
+ 'confidence' => 0.0,
+ 'reason' => 'Empty citation context or reference text',
+ ];
+ }
+
+ $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);
+ }
+
+ if ($isAgain) {
+ $system = $this->buildReferenceCheckSecondPassPrompt();
+ $user = $this->buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock);
+ } else {
+ $system = $this->buildReferenceCheckFirstPassPrompt();
+ $user = $this->buildReferenceCheckFirstPassUserPrompt($contextText, $referText);
+ }
+
+ \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,
+ 'messages' => [
+ ['role' => 'system', 'content' => $system],
+ ['role' => 'user', 'content' => $user],
+ ],
+ ];
+
+ $content = $this->postChat($payload);
+ if ($content === null) {
+ \think\Log::warning('ReferenceCheck LLM: postChat returned null');
+ return $fallback;
+ }
+
+ $parsed = $this->parseJson($content);
+ if ($parsed === null) {
+ \think\Log::warning('ReferenceCheck LLM: parseJson failed, raw=' . mb_substr($content, 0, 500));
+ return $fallback;
+ }
+
+ $canSupport = $this->parseCanSupportFromParsed($parsed);
+ $confidence = $this->snapReferenceCheckConfidence(
+ $this->normalizeConfidence(isset($parsed['confidence']) ? $parsed['confidence'] : 0),
+ $canSupport
+ );
+ $reason = $this->cleanReason((string)(isset($parsed['reason']) ? $parsed['reason'] : ''));
+ \think\Log::info(
+ 'ReferenceCheck result: can_support=' . ($canSupport ? '1' : '0')
+ . ', confidence=' . $confidence
+ . ', reason=' . $reason
+ );
+ return [
+ '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 $this->buildReferenceCheckFirstPassPrompt();
+ }
+
+ /**
+ * 护理/医学期刊:正文引用句与参考文献条目的相关性校对
+ */
+ private function buildReferenceCheckSystemPrompt2()
+ {
+ return <<<'PROMPT'
+你是一名护理与医学期刊的资深编辑,专门校对「正文引用句」与「对应参考文献条目」是否匹配。
+你只能依据用户提供的两段文本判断,不得假设已阅读全文,不得编造文献中未出现的信息。
+
+## 校对目标
+判断:作者在该引用位置引用的观点/数据/结论/方法/定义,是否可由该条参考文献合理支撑(主题与论证层面是否对得上)。
+
+## 评估步骤(按顺序,在心里完成即可)
+1. 主题域:正文句子的核心主题(疾病、人群、干预、结局、理论、政策等)与文献题目/作者/期刊/年份/条目内容是否属于同一专业领域。
+2. 论点对齐:正文句子的关键断言,是否与该文献可能报告的内容方向一致(允许概括性引用,但不可张冠李戴)。
+3. 错引排查:是否出现「仅同一大领域但具体对象不同」「人群/场景/指标明显不符」「把指南/综述/原始研究混用导致支撑关系不成立」等常见错引。
+4. 信息不足:若文献条目过简(仅作者+年份等),只能做粗判;若完全无法建立合理关联,按不匹配处理。
+
+## is_match 判定(二选一,必须一致)
+- true:主题明确相关,且引用句的核心信息与该文献可能内容高度吻合或可被其合理概括支撑。
+- false:主题无关、明显错引、具体论点对不上、或无法建立合理关联。边界不清时从严标 false(降低漏报错引风险)。
+
+## confidence 评分(稳定性要求:只能使用下列 6 个固定值之一,禁止 0.72、0.8 等其它小数)
+| 分值 | 含义 | 通常配合 is_match |
+| 0.95 | 高度匹配:主题、对象、论点均清晰对应 | true |
+| 0.85 | 较匹配:主题与论点一致,表述略宽但仍可接受 | true |
+| 0.75 | 基本匹配:大方向对,有轻微不精确或概括过度 | true |
+| 0.35 | 存疑:同领域但具体对不上,或信息不足,建议人工复核 | false |
+| 0.25 | 较可能错引:主题或论点明显偏离 | false |
+| 0.15 | 明确错引:主题无关或典型张冠李戴 | false |
+
+硬性规则(必须遵守,否则视为无效输出):
+- is_match=true 时,confidence 只能是 0.75、0.85 或 0.95。
+- is_match=false 时,confidence 只能是 0.15、0.25 或 0.35。
+- 禁止输出 0.5、0.6、0.9 等未列出的 confidence 值。
+
+## 评分稳定原则
+- 相同输入应得到相同结论;不要因措辞风格波动而改变档位。
+- 优先依据「主题 + 关键断言」而非个别泛化词(如「研究」「护理」「患者」)判匹配。
+- 一句多引时,只评价当前这一条文献与引用句的关系,勿与其它序号混淆。
+
+## 输出格式(仅输出一行 minified JSON,无 markdown、无前后说明)
+{"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"
+ . $contextText
+ . "\n\n【对应参考文献条目】(书目信息,可能不完整)\n"
+ . $referText
+ . "\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 档位
+ */
+ private function getReferenceCheckConfidenceBands($isMatch)
+ {
+ return $isMatch
+ ? [0.65, 0.78, 0.85, 0.92, 0.98]
+ : [0.15, 0.25, 0.35, 0.45];
+ }
+
+ /**
+ * 将模型输出的 confidence 吸附到合法档位(如 0.95 → 0.92,0.75 → 0.78)
+ */
+ private function snapReferenceCheckConfidence($confidence, $isMatch)
+ {
+ $bands = $this->getReferenceCheckConfidenceBands($isMatch);
+
+ foreach ($bands as $band) {
+ if (abs($confidence - $band) < 0.001) {
+ return $band;
+ }
+ }
+
+ $nearest = $bands[0];
+ $minDiff = abs($confidence - $nearest);
+ foreach ($bands as $band) {
+ $diff = abs($confidence - $band);
+ if ($diff < $minDiff) {
+ $minDiff = $diff;
+ $nearest = $band;
+ }
+ }
+
+ return $nearest;
+ }
+
+ private function postChat(array $payload)
+ {
+ try{
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $this->url);
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(15, $this->timeout));
+ curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
+
+ $headers = ['Content-Type: application/json'];
+ if ($this->apiKey !== '') {
+ $headers[] = 'Authorization: Bearer ' . $this->apiKey;
+ }
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+
+ $raw = curl_exec($ch);
+ if ($raw === false) {
+ \think\Log::warning('ReferenceCheck LLM curl error: ' . curl_error($ch));
+ curl_close($ch);
+ return null;
+ }
+ $httpCode = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE));
+ curl_close($ch);
+ if ($httpCode < 200 || $httpCode >= 300) {
+ \think\Log::warning('ReferenceCheck LLM http ' . $httpCode . ': ' . mb_substr((string)$raw, 0, 500));
+ return null;
+ }
+
+ $data = json_decode($raw, true);
+ if (!is_array($data)) {
+ return null;
+ }
+ if (isset($data['choices'][0]['message']['content'])) {
+ return (string)$data['choices'][0]['message']['content'];
+ }
+ if (isset($data['content'])) {
+ return (string)$data['content'];
+ }
+ } catch (Exception $exception) {
+ \think\Log::warning('ReferenceCheck LLM exception: ' . $exception->getMessage());
+ }
+
+ return null;
+ }
+
+ private function parseJson($raw)
+ {
+ $raw = trim($raw);
+ if ($raw === '') {
+ return null;
+ }
+ $raw = preg_replace('/^```[a-zA-Z]*\s*|```$/m', '', $raw);
+ $raw = trim($raw);
+
+ $obj = json_decode($raw, true);
+ if (is_array($obj)) {
+ return $obj;
+ }
+ if (preg_match('/\{.*\}/s', $raw, $m)) {
+ $obj = json_decode($m[0], true);
+ if (is_array($obj)) {
+ return $obj;
+ }
+ }
+ return null;
+ }
+
+ private function normalizeConfidence($value)
+ {
+ if (!is_numeric($value)) {
+ return 0.0;
+ }
+ $v = (float)$value;
+ if ($v > 1.0 && $v <= 100.0) {
+ $v = $v / 100.0;
+ }
+ return round(max(0.0, min(1.0, $v)), 4);
+ }
+
+ private function cleanReason($text)
+ {
+ $text = trim(preg_replace('/\s+/', ' ', $text));
+ if (mb_strlen($text) > 500) {
+ $text = mb_substr($text, 0, 500);
+ }
+ return $text !== '' ? $text : 'No reason provided';
+ }
+}