Files
tougao/application/common/service/LLMService.php
2026-06-29 10:23:27 +08:00

1916 lines
49 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common\service;
use think\Env;
use think\Exception;
/**
* 引用文献 LLM 校对(与 PromotionLlmService 共用 [promotion] 大模型配置)
*/
class LLMService
{
private $url;
private $model;
private $apiKey;
private $timeout;
public function __construct()
{
$this->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 真实文献内容(仅二次复核使用)
* @param string $citeGroupRefs 引用文献组,如 1,2 或 4,5,6
* @param string $localContext 本引用位置附近上下文(可选)
* @return array{results:array,request_failed?:bool}
*/
public function checkReference($contextText, $referText, $isAgain = false, $doiBlock = null, $citeGroupRefs = '', $localContext = '')
{
$fallback = [
'results' => [],
'request_failed' => true,
'reason' => 'LLM not configured or request failed',
];
if ($this->url === '' || $this->model === '') {
\think\Log::warning('ReferenceCheck LLM: url or model not configured');
return $fallback;
}
$contextText = trim($contextText);
\think\Log::info('llm checkReference:' . $contextText);
$referText = trim($referText);
\think\Log::info('llm referText:' . $referText);
$doiBlock = trim((string)$doiBlock);
$citeGroupRefs = trim((string)$citeGroupRefs);
$localContext = trim((string)$localContext);
if ($contextText === '' || $referText === '') {
return [
'results' => [],
'reason' => 'Empty citation context or reference text',
];
}
$maxContextLen = 8000;
if (mb_strlen($contextText) > $maxContextLen) {
$contextText = mb_substr($contextText, 0, $maxContextLen);
}
if (mb_strlen($localContext) > 3000) {
$localContext = mb_substr($localContext, 0, 3000);
}
if (mb_strlen($referText) > 6000) {
$referText = mb_substr($referText, 0, 6000);
}
if (mb_strlen($doiBlock) > 8000) {
$doiBlock = mb_substr($doiBlock, 0, 8000);
}
if ($isAgain) {
$system = $this->buildReferenceCheckSecondPassPrompt();
$user = $this->buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock, $citeGroupRefs, $localContext);
} else {
$system = $this->buildReferenceCheckFirstPassPrompt();
$user = $this->buildReferenceCheckFirstPassUserPrompt($contextText, $referText, $citeGroupRefs, $localContext, $doiBlock);
}
// \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;
}
$results = $this->parseReferenceCheckResultsFromParsed($parsed, $citeGroupRefs, $localContext, $doiBlock);
if (empty($results)) {
\think\Log::warning('ReferenceCheck LLM: empty results array');
return $fallback;
}
\think\Log::info($results);
return ['results' => $results];
}
/**
* 通用对话请求(与参考文献校对共用 postChat / [promotion] 配置)
*
* @param array $messages OpenAI messages
* @param float $temperature
* @return string|null 助手回复正文
*/
public function requestChat(array $messages, $temperature = 0)
{
if ($this->url === '' || $this->model === '') {
\think\Log::warning('LLM requestChat: url or model not configured');
return null;
}
$payload = [
'model' => $this->model,
'temperature' => $temperature,
'messages' => $messages,
];
return $this->postChat($payload);
}
/**
* 解析模型返回的 JSON 对象(去除 markdown 代码块等)
*/
public function parseJsonResponse($raw)
{
return $this->parseJson($raw);
}
/**
* 解析 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);
}
private function bulidReferenceCheckFirstPassPrompt(){
return <<<'PROMPT'
你是一名护理、医学与科研期刊的资深文献编辑,专门校对「正文引用句」与「对应参考文献条目」是否匹配。
你的目标是严格识别错引、张冠李戴、方法不符、对象不符、结论不成立的问题。
宁可少判 true也不要漏掉错引。
你只能依据用户提供的内容判断:
1. 正文引用句
2. 当前对应参考文献条目
禁止假设已阅读全文。
禁止联网。
禁止脑补文献内容。
禁止根据学科常识推断研究结果。
====================
【核心任务】
判断:
正文在该引用位置表达的核心观点、结论、方法、数据、定义、模型、研究发现、指南依据等,
是否能够被该条参考文献合理支撑。
你判断的是:
“引用是否成立”
不是:
“正文是否正确”。
====================
【总原则(最高优先级)】
采用严格审稿标准:
边界不清时,一律判 false。
宁可误杀(人工复核),不要漏掉错引。
同领域 ≠ 匹配。
同关键词 ≠ 匹配。
相关 ≠ 能支撑。
====================
【强制规则】
1. 严禁关键词硬匹配
不能因为出现:
患者、护理、治疗、研究、模型、算法、深度学习、机器学习、焦虑、效果
等泛化词汇就判定匹配。
必须看:
- 核心对象
- 研究问题
- 方法
- 场景
- 结局指标
- 核心论点
是否一致。
====================
2. 方法学必须严格一致(极重要)
若正文明确提到:
- 算法
- 模型
- 聚类方法
- 深度学习架构
- 统计方法
- 数学模型
- 评价指标
必须要求文献与其存在明确关联。
例如:
不匹配:
- fuzzy clustering ≠ deep learning
- CNN ≠ LSTM
- random forest ≠ SVM
- 聚类 ≠ 分类
- 特征选择 ≠ 分类预测
- 风险因素分析 ≠ 干预研究
仅属于同一“大领域AI/ML
不能判定匹配。
若方法体系不同:
优先判 false + 0.10。
====================
3. 医学护理引用严格一致
若正文涉及:
- 疾病
- 人群
- 护理场景
- 干预措施
- 结局指标
必须基本一致。
例如:
不匹配:
- ICU ≠ 普通病房
- 老年人 ≠ 儿童
- 糖尿病 ≠ 高血压
- 心理护理 ≠ 运动干预
- 焦虑改善 ≠ 生存率提高
====================
4. 强结论必须强证据
正文若出现:
- 显著改善
- 明显降低
- 证实
- 优于
- 有效预测
- 危险因素
- 因果关系
文献必须能合理支撑该强结论。
仅“应用研究”“相关研究”“观察研究”
不能自动支持强结论。
否则 false。
====================
5. 特定证据类型必须一致
正文若明确写:
- RCT/randomized trial
- Meta-analysis
- Guideline
- Systematic review
- Expert consensus
而参考文献类型明显不符:
直接 false。
====================
6. 信息不足从严
若参考文献只有:
作者 + 年份
或信息过少,
无法建立明确关联:
false + 0.30
====================
【判定逻辑】
只有同时满足以下条件,才能 true
1. 主题一致
2. 核心对象一致
3. 核心论点一致
4. 方法/研究方向一致
5. 无明显错引风险
任意一点明显不符:
false。
====================
【评分(只能四选一)】
只能输出:
0.90
0.75
0.30
0.10
禁止任何其他分数。
评分规则:
0.90
明确匹配:
主题、对象、方法、核心论点均明显一致。
0.75
基本匹配:
整体支撑成立,但存在轻微概括或小范围表述差异。
0.30
存疑:
同领域但支撑不足;
信息不足;
需人工复核。
0.10
明确错引:
主题、对象、方法或核心论点明显不符。
硬规则:
is_match=true
只能:
0.75 或 0.90
is_match=false
只能:
0.10 或 0.30
====================
【reason 要求】
仅说明:
1. 是否主题一致;
2. 核心论点/方法是否能支撑。
禁止模糊措辞:
“可能”
“看起来”
“应该”
“疑似”
长度:
20~60字。
====================
【输出要求】
仅输出一行 minified JSON。
禁止 markdown。
禁止解释。
禁止换行。
禁止任何额外内容。
格式:
{"is_match":true|false,"confidence":0.10|0.30|0.75|0.90,"reason":"简体中文说明"}
PROMPT;
}
/** 第一次校对:参考文献真实性与支撑力度 */
private function buildReferenceCheckFirstPassPrompt()
{
return $this->buildReferenceCheckSupportSystemPrompt(false);
}
private function buildReferenceCheckSupportSystemPrompt($isSecondPass = false)
{
$prompt = <<<'PROMPT'
你是一名护理、医学、生物医学与科研期刊的资深学术编辑,正在执行“参考文献真实性与支撑力度校对”。
你的任务不是判断“主题是否相关”,而是判断:
【稿件正文中某段被引用内容】是否真的能被【对应编号的参考文献】直接或充分支撑。
你必须严格基于用户提供的材料作出判断,不得凭常识、不得脑补、不得假设参考文献中“可能写过但未提供”的内容。
==================================================
【一、任务目标】
你需要判断:
“正文引用位置的核心论点、结论、背景陈述、机制解释、疗效描述、数据表达或因果表述,
是否能被对应参考文献真实支持。”
这里的“支持”不是指“文献主题相关”或“研究领域接近”,而是指:
参考文献中确实包含足以支持正文该处表述的内容。
==================================================
【二、输出原则:结果必须直接对应数据库行】
你输出的结果将直接写入数据库表 t_article_reference_check_result。
因此:
## 输出必须是 results 数组,数组中的每一个对象对应数据库中的一行,也就是“一个引用位置中的一条参考文献结果”。
换句话说:
- 如果某个引用位置是 [3],则输出 1 条 resultreference_no=3
- 如果某个引用位置是 [1,2],则输出 2 条 result
- 一条对应 reference_no=1
- 一条对应 reference_no=2
每条 result 都必须给出该参考文献“单独”对正文引用句的支撑判断。
如果该引用位置是联合引用citation group 中有多篇文献则除了单条判断外还必须给出该引用组整体的联合判断combined_* 字段)。
==================================================
【三、最重要原则:只看“是否支撑正文核心断言”,不是看“主题是否沾边”】
以下情况不能判为强支撑:
1. 参考文献只和主题大致相关,但没有明确支持正文中的关键表述
2. 正文说的是“疗效提升/死亡率下降/全球高发/耐药/多通路机制”等明确论点,而文献只是在背景里泛泛提到疾病
3. 正文是多层复合句,文献只支撑其中一小部分
4. 正文有因果、比较、趋势、机制、疗效强度等强表述,而文献没有明确证据
5. 文献是基础机制研究,但正文引用它来支撑宏观流行病学、临床治疗现状或指南式结论
6. 文献可以“推测支持”但不是“直接/明确支持”
==================================================
【三b、多 claim 复合句 → 0.78 部分支撑(勿误降到 0.45)】
正文常为 2~4 个连续 claim 的复合句。须逐 claim 比对后综合给分:
- 若文献(含 DOI 摘要)能**明确支撑多数关键概念**(如遗传异质性/多基因改变、多 survival pathway 并存、耐药或治疗挑战),
但**未逐字写出**正文完整因果链(如「异质性→多通路→单靶点疗效下降」),
→ 应判 **partial_support**confidence 通常 **0.78**(边界情况 0.65**不得**仅因文献主标题聚焦某化合物/干预就降到 0.45。
- 0.45 仅用于:文献与 claim 方向明显不符、仅同病沾边、或几乎无可用证据。
**校准样例(单条 [4],须接近此逻辑):**
引用句:
Furthermore, the genomic heterogeneity of colorectal cancer (CRC) presents additional difficulties because tumors frequently make use of several survival pathways at once, which reduces the efficacy of single-target treatments [4].
文献4Sheikhnia et al., thymoquinone CRC 机制综述):
- Claim1 遗传异质性/多基因改变:文献有 APC/KRAS/TP53、MSI/CIN 等 → 支撑较强
- Claim2 多 survival pathway文献列举 PI3K/Akt、Wnt、STAT3、NF-κB 等多通路 → 支撑较强
- Claim3 单靶点疗效下降:文献有 drug resistance/治疗挑战,但未直述因果链 → 部分支撑
- **输出**can_support=1, confidence=**0.78**, support_role=supplementary_support**不是 0.45**
用户消息中若提供【DOI 真实文献内容】,**必须结合摘要判断**,不得仅凭书目标题给分。
==================================================
【四、评分规则】
你必须使用以下 8 个固定分值之一:
0.98 / 0.92 / 0.85 / 0.78 / 0.65 / 0.45 / 0.25 / 0.15
判定含义:
- 0.98 / 0.92 / 0.85 => 强支撑strong_support
- 0.78 / 0.65 => 部分支撑partial_support
- 0.45 / 0.25 => 支撑不足insufficient_support
- 0.15 => 不支撑not_support
can_support 取值规则:
- 若该文献/联合引文整体可判为 strong_support 或 partial_support则 can_support = 1
- 若判为 insufficient_support 或 not_support则 can_support = 0
==================================================
【五、单条文献结果如何判断】
对于每一条参考文献,你必须判断它“单独”能否支撑该引用位置的正文内容,并输出:
- can_support
- confidence
- reason
- support_role
其中:
### support_role 只能取以下值之一
- primary_support该文献本身就是主要证据来源能支撑引用句核心内容
- supplementary_support能支撑部分重要内容但不是主要来源
- minimal_support只提供少量背景或边缘支撑
- no_meaningful_support几乎不能支撑该引用句
### reason 的写法要求
必须使用中文,明确写出:
1. 这篇文献具体支撑正文的哪一部分
2. 哪些部分没有支撑到
3. 是否存在文献类型与引用用途不匹配的问题
4. 为什么给这个分值,而不是更高或更低
==================================================
【六、联合引用的判断规则】
当同一个引用位置包含多篇参考文献时(例如 [1,2] / [4,5,6]),除了逐条给单条结果外,还要额外判断:
“这些文献合起来,是否足以支撑该引用位置的正文内容?”
联合结论输出到:
- combined_can_support
- combined_confidence
- combined_reason
规则:
1. 联合评分不是单条评分平均值
2. 如果其中一篇文献已强支撑,其他文献只是补充,则联合评分可接近主支撑文献
3. 如果多篇文献分别覆盖不同部分,合起来能较完整支撑正文,则联合评分可以高于某些单条评分
4. 但如果最关键的核心断言没有被任何文献明确支撑,则联合评分不能虚高
5. 如果多篇文献都只是零散相关,需要大量推断才能拼出正文结论,则联合评分通常不应过高
==================================================
【七、单引文的 combined_* 字段处理规则】
即使某个引用位置只有 1 条参考文献,也仍然必须输出 combined_* 字段。
此时:
- combined_can_support = can_support
- combined_confidence = confidence
- combined_reason = “该引用位置仅包含单条文献,联合结论等同于该文献的单条结论。” 或等价表述
这样可以保证输出结构统一,便于数据库写入。
==================================================
【八、输出 JSON 结构】
你必须输出合法 JSON且只能输出以下结构
{
"results": [
{
"reference_no": 1,
"cite_group_refs": "1,2",
"can_support": 0,
"confidence": 0.65,
"reason": "中文,单条文献结论",
"support_role": "supplementary_support",
"combined_can_support": 1,
"combined_confidence": 0.85,
"combined_reason": "中文,联合引用整体结论"
}
]
}
==================================================
【九、字段约束】
### 1results 中每个对象都必须包含以下字段:
- reference_no
- cite_group_refs
- can_support
- confidence
- reason
- support_role
- combined_can_support
- combined_confidence
- combined_reason
### 2reference_no
必须对应当前引用位置中的某一条参考文献编号。
### 3cite_group_refs
必须是该引用位置的完整引文组,格式如:
- "3"
- "1,2"
- "4,5,6"
### 4同一引用位置若包含多条参考文献则必须输出多条 result
例如 cite_group_refs = "1,2" 时,必须输出:
- 一条 reference_no=1
- 一条 reference_no=2
### 5同一引用位置下的 combined_* 必须一致
例如同属 "1,2" 的两条 result它们的
- combined_can_support
- combined_confidence
- combined_reason
必须完全一致。
==================================================
【十、禁止事项】
你绝对不能:
- 杜撰文献中不存在的结论
- 把“主题相关”当作“内容支撑”
- 因为是同一疾病就默认支持
- 输出 JSON 以外的任何内容
现在开始,读取用户提供的引用位置正文、参考文献信息和文献内容,输出结果。
PROMPT;
if ($isSecondPass) {
$prompt .= <<<'PROMPT'
==================================================
【二次校对补充DOI 真实文献内容)】
用户消息中会提供【DOI 真实文献内容PubMed/Crossref】。
必须以 DOI 真实内容为准复核支撑力度;书目信息与 DOI 冲突时以 DOI 为准。
仍须输出完整 results 数组,逐条给出单文献判断与联合判断。
PROMPT;
}
return $prompt;
}
private function buildReferenceCheckFirstPassUserPrompt($contextText, $referText, $citeGroupRefs = '', $localContext = '', $doiBlock = '')
{
return $this->buildReferenceCheckSupportUserPrompt($contextText, $referText, $citeGroupRefs, $localContext, $doiBlock);
}
private function buildReferenceCheckSupportUserPrompt($contextText, $referText, $citeGroupRefs, $localContext, $doiBlock)
{
$citeGroupRefs = trim((string)$citeGroupRefs);
$localContext = trim((string)$localContext);
$doiBlock = trim((string)$doiBlock);
$parts = [
"【正文节 t_article_main】\n" . $contextText,
];
if ($citeGroupRefs !== '') {
$mode = strpos($citeGroupRefs, ',') !== false ? '联合引用' : '单独引用';
$parts[] = "【引用文献组 cite_group_refs】{$citeGroupRefs}{$mode}";
}
if ($localContext !== '') {
$parts[] = "【本引用位置附近上下文】\n" . $localContext;
}
$parts[] = "【参考文献书目(按编号列出)】\n" . $referText;
if ($doiBlock !== '') {
$parts[] = "【DOI 真实文献内容PubMed/Crossref一轮校对已提供\n" . $doiBlock;
}
$parts[] = '请严格按 system 要求输出 results 数组 JSON每条 result 对应一个 reference_no并包含 combined_* 字段。';
return implode("\n\n", $parts);
}
/** 第二次校对DOI 真实文献内容复核 */
private function buildReferenceCheckSecondPassPrompt()
{
return $this->buildReferenceCheckSupportSystemPrompt(true);
}
private function buildReferenceCheckSecondPassUserPrompt($contextText, $referText, $doiBlock, $citeGroupRefs = '', $localContext = '')
{
return $this->buildReferenceCheckSupportUserPrompt(
$contextText,
$referText,
$citeGroupRefs,
$localContext,
$doiBlock !== '' ? $doiBlock : '(未获取到 DOI 摘要或元数据,请结合书目条目从严判断)'
);
}
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字
- 仅说明:
1DOI文献研究内容
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字。
必须说明:
1DOI 文献研究什么;
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, '', '');
}
/**
* @return array<int, array>
*/
private function parseReferenceCheckResultsFromParsed(array $parsed, $defaultCiteGroupRefs = '', $localContext = '', $doiBlock = '')
{
$rows = [];
if (isset($parsed['results']) && is_array($parsed['results'])) {
$rows = $parsed['results'];
} elseif (isset($parsed['reference_no']) || isset($parsed['confidence'])) {
$rows = [$parsed];
}
$normalized = [];
foreach ($rows as $item) {
if (!is_array($item)) {
continue;
}
$refNo = intval(isset($item['reference_no']) ? $item['reference_no'] : 0);
if ($refNo <= 0) {
continue;
}
$confidence = $this->snapReferenceCheckConfidenceValue(
$this->normalizeConfidence(isset($item['confidence']) ? $item['confidence'] : 0)
);
$canSupport = $this->canSupportFromConfidence($confidence);
if (array_key_exists('can_support', $item)) {
$canSupport = $this->boolFromLlmValue($item['can_support']);
} elseif (array_key_exists('is_match', $item)) {
$canSupport = $this->boolFromLlmValue($item['is_match']);
}
$reason = $this->cleanReason((string)(isset($item['reason']) ? $item['reason'] : ''));
$supportRole = $this->normalizeSupportRole(isset($item['support_role']) ? $item['support_role'] : '');
list($confidence, $canSupport, $supportRole) = $this->applyMultiClaimPartialSupportFloor(
$localContext,
$doiBlock,
$confidence,
$canSupport,
$supportRole,
$reason
);
$combinedConfidence = $this->snapReferenceCheckConfidenceValue(
$this->normalizeConfidence(isset($item['combined_confidence']) ? $item['combined_confidence'] : $confidence)
);
$combinedCanSupport = $this->canSupportFromConfidence($combinedConfidence);
if (array_key_exists('combined_can_support', $item)) {
$combinedCanSupport = $this->boolFromLlmValue($item['combined_can_support']);
}
$citeGroupRefs = trim((string)(isset($item['cite_group_refs']) ? $item['cite_group_refs'] : $defaultCiteGroupRefs));
if ($citeGroupRefs === '' && $defaultCiteGroupRefs !== '') {
$citeGroupRefs = trim((string)$defaultCiteGroupRefs);
}
$normalized[] = [
'reference_no' => $refNo,
'cite_group_refs' => $citeGroupRefs,
'can_support' => $canSupport,
'is_match' => $canSupport,
'confidence' => $confidence,
'reason' => $reason,
'support_role' => $supportRole,
'combined_can_support' => $combinedCanSupport,
'combined_confidence' => $combinedConfidence,
'combined_reason' => $this->cleanReason((string)(isset($item['combined_reason']) ? $item['combined_reason'] : '')),
];
}
return $normalized;
}
private function normalizeSupportRole($role)
{
$role = strtolower(trim((string)$role));
$allowed = [
'primary_support',
'supplementary_support',
'minimal_support',
'no_meaningful_support',
];
return in_array($role, $allowed, true) ? $role : 'no_meaningful_support';
}
private function canSupportFromConfidence($confidence)
{
return floatval($confidence) >= 0.65 - 0.001;
}
/**
* 多通路/异质性 claim + DOI 有多通路证据时,防止误打 0.45(应对齐 0.78 部分支撑)
*/
private function applyMultiClaimPartialSupportFloor($localContext, $doiBlock, $confidence, $canSupport, $supportRole, $reason)
{
$confidence = floatval($confidence);
if ($confidence > 0.45) {
return [$confidence, $canSupport, $supportRole];
}
$claimText = trim((string)$localContext);
if ($claimText === '') {
return [$confidence, $canSupport, $supportRole];
}
$claimIsMechanism = (bool)preg_match(
'/\b(genomic heterogeneity|heterogeneity|survival pathway|pathways at once|single-target|multi.?pathway|genetic alteration|drug resistance|异质性|生存通路|多.*通路|单靶点|耐药)\b/ui',
$claimText
);
if (!$claimIsMechanism) {
return [$confidence, $canSupport, $supportRole];
}
$corpus = trim((string)$doiBlock) . ' ' . trim((string)$reason);
if ($corpus === '') {
return [$confidence, $canSupport, $supportRole];
}
$refHasPathwayEvidence = (bool)preg_match(
'/\b(pathway|PI3K|Akt|mTOR|Wnt|STAT3|NF-κB|NF-kB|genetic alteration|MSI|CIN|drug resistance|signaling|multiple|APC|KRAS|TP53|通路|耐药|信号)\b/ui',
$corpus
);
if (!$refHasPathwayEvidence) {
return [$confidence, $canSupport, $supportRole];
}
$confidence = 0.78;
$canSupport = true;
if ($supportRole === 'no_meaningful_support' || $supportRole === 'minimal_support') {
$supportRole = 'supplementary_support';
}
return [$confidence, $canSupport, $supportRole];
}
private function getReferenceCheckConfidenceBands()
{
return [0.15, 0.25, 0.45, 0.65, 0.78, 0.85, 0.92, 0.98];
}
private function snapReferenceCheckConfidenceValue($confidence)
{
$bands = $this->getReferenceCheckConfidenceBands();
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;
}
/**
* @deprecated 兼容旧逻辑
*/
private function getReferenceCheckConfidenceBandsLegacy($isMatch)
{
return $isMatch
? [0.65, 0.78, 0.85, 0.92, 0.98]
: [0.15, 0.25, 0.35, 0.45];
}
/**
* 将模型输出的 confidence 吸附到合法档位
*/
private function snapReferenceCheckConfidence($confidence, $isMatch)
{
$snapped = $this->snapReferenceCheckConfidenceValue($confidence);
$bands = $this->getReferenceCheckConfidenceBandsLegacy($isMatch);
if (in_array($snapped, $bands, true)) {
return $snapped;
}
foreach ($bands as $band) {
if (abs($snapped - $band) < 0.001) {
return $band;
}
}
$nearest = $bands[0];
$minDiff = abs($snapped - $nearest);
foreach ($bands as $band) {
$diff = abs($snapped - $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';
}
}